#!/usr/bin/env python

# TUIWEATHERGIRL
# 2026 by Evgueni Antonov (StrayF)

# NOTE: This application was written on Python 3.12.3
# and was tested on the official Python 3.9:bookworm Docker image for
# compatibility.

from __future__ import annotations

import sys

# Let's handle this in the very begining to avoid losing time later
if sys.version_info < (3, 9):
    raise RuntimeError(
        f"This application requires Python 3.9 or higher. "
        f"You are currently running Python {sys.version.split()[0]}."
    )

import argparse
import concurrent.futures
import configparser
import csv
import curses
import logging
import math
import os
import pickle
import random
import re
import shutil
import signal
import socket
import stat
import subprocess
import tempfile
import textwrap
import time
import traceback
import unicodedata
import zipfile
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from pprint import pformat as pf
from pprint import pprint as pp
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import requests
from babel import Locale
from babel.dates import format_date
from babel.languages import get_official_languages
from packaging.version import parse as parse_version

# Normally I don't leave global variables hanging in the source but
# I intentionally left these here, as I tend to change them time to time
# and don't want to scroll too much to find them

__version__: str = "1.5.12"

DEBUG_MODE: bool = False
DEFAULT_VIEW: str = "dashboard"
DEFAULT_THEME: str = "main"
MAX_CITIES: int = 11  # This includes the home city
DESCRIPTION_HELP: str = (
    f"TUIWEATHERGIRL {__version__} by Evgueni Antonov (StrayF) 2026. Weather and disaster station."
)
EPILOGUE_HELP: str = f"""VIEWS:
    motivate and basic
        Best for barebone terminals/tty (simple STDOUT). Try Motivate!
    
    ttydashboard
        A colorful ncurses dashboard, made for pure virtual console of 106x33.

    dashboard
        MAIN DASHBOARD - tons of information.
        Requires terminal size at least 146x38.

    setup
        Prints the currently set cities.

NOTE: --addcity and --country must be passed together

HOW DOES TUIWEATHERGIRL WORKS:
    - The app would auto-configure. You may edit the config, but if you mess
      it up - delete it.
    - You may edit the config, but if you mess-it up, better run the app
      with --clearcache
    - You may add up to {MAX_CITIES - 1} additional cities, but not every view
      will show them

PROJECT URL: https://github.com/StrayFeral/tuiweathergirl
For detailed wildfires info set variable NASAFIRMSAPIKEY with a free API key
from: https://firms.modaps.eosdis.nasa.gov/api/map_key
"""
APPLICATION_UPDATE_POLICY: str = f"""
TUIWEATHERGIRL APPLICATION UPDATE POLICY
========================================

1) If you installed this application directly from GitHub, you can update
   it manually (auto-update is ON by default).

2) If you installed this application via the system package manager
   (apt, dnf, pacman, zypper, apk, ... etc)
   updates must be managed exclusively through your system's package manager
   to maintain system integrity. Built-in updates are disabled.

3) To get a newer version not yet available in your distribution's
   repositories, uninstall the package using your package manager, then install
   directly from the project GitHub:

   https://github.com/StrayFeral/tuiweathergirl
"""
USERAGENT: str = (
    f"TUIWeatherGirl/{__version__} (https://github.com/StrayFeral/tuiweathergirl)"
)
HTTPHEADERS: dict[str, str] = {
    "User-Agent": USERAGENT,
    "Accept-Language": "en",
}
NASAFIRMS_APIKEY_ENV_VARNAME: str = "NASAFIRMSAPIKEY"
LOGFILENAME: Path = Path(tempfile.gettempdir()) / "tuiweathergirl.log"
LOGFILENAME = LOGFILENAME.resolve()
REQTIMEOUT: int = 5
MIN_COLS: int = 79
MIN_LINES: int = 22
MAXSTRINGLEN: int = 140
SUBMIT_BUG: str = (
    "Submit a bug to the project GitHub page, attach your config file and your logfile."
)
REFRESH_INTERVAL: int = 30  # minutes
REFRESH_INTERVAL_ON_FAIL: int = 3  # minutes
DEFAULT_LOCALE: str = "en_US"  # The fallback plan

# Color indexes
COL_YELLOWRED: int = 1
COL_YELLOWBLACK: int = 2
COL_WHITEBLACK: int = 3
COL_BLUEBLACK: int = 4
COL_REDBLACK: int = 5
COL_GREENBLACK: int = 6
COL_CYANBLACK: int = 7


class InstanceGuard:
    @staticmethod
    def ensure_single_instance(port: int = 47382) -> None:
        global LOCKSOCKET
        ip: str = "127.0.0.1"
        LOCKSOCKET = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        LOCKSOCKET.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            LOCKSOCKET.bind((ip, port))
        except socket.error:
            raise Exception(
                f"Error: Application is already running. If you suspect another instance stalled, please check applications listening at {ip}:{port}."
            )


class APIIssues:
    _API_PROBLEMS: dict[int, str] = {
        400: f"Bad request. Fix the API request. {SUBMIT_BUG}",
        401: "You have been PERMANENTLY banned by the API. There is nothing I could do to help. Sorry.",
        402: "Payment required.",
        403: "You have been soft-banned by the API. Please wait 24 hours and try again.",
        407: "Proxy Authentication Required.",
        408: "Request Timeout.",
        410: f"Seems the API provider does not exist anymore. {SUBMIT_BUG}",
        429: "You have been temporarely banned by the API. Please wait 2 minutes and try again.",
        500: "The API server crashed. Please try again in few hours.",
        502: "API server problem. Please try again in few hours.",
        503: "The API server is down for maintenance. Please try again in few hours.",
        504: "Problem with the API server. Please try again in 10 minutes",
        505: f"HTTP version not supported. {SUBMIT_BUG}",
    }

    @staticmethod
    def get_api_problem(http_code: int) -> str:
        return (
            APIIssues._API_PROBLEMS[http_code]
            if http_code in APIIssues._API_PROBLEMS
            else ""
        )


# Yes, I know you have questions by looking at this.
# No worries. I have questions too.
facts_sheet: list[str] = [
    "1948-01-01: Konrad Zuse completes Plankalkül, the earliest high-level non-von Neumann programming language.",
    "1949-01-01: John Mauchly proposes Short Code, the first electronic programming language for computers.",
    "1952-01-01: Alick Glennie develops Autocode, considered the first compiled programming language.",
    "1957-04-15: IBM releases FORTRAN, the first widely used high-level general-purpose programming language.",
    "1958-01-01: ALGOL is designed by an international committee, introducing structured code blocks and scoping.",
    "1958-03-01: John McCarthy develops LISP, pioneering garbage collection, tree structures, and dynamic typing.",
    "1959-09-01: CODASYL creates COBOL, designed specifically for business data processing systems.",
    "1962-01-01: Ole-Johan Dahl and Kristen Nygaard create Simula, introducing object-oriented programming.",
    "1964-05-01: John G. Kemeny and Thomas E. Kurtz design BASIC to give non-science students access to computers.",
    "1966-01-01: Kenneth E. Iverson creates APL, an array-oriented language using unique mathematical notation.",
    "1970-01-01: Niklaus Wirth releases Pascal, designed as a structured programming tool for education.",
    "1971-11-03: AT&T Bell Labs releases Unix 1st Edition for PDP-11, created by Ritchie and Thompson.",
    "1972-01-01: Dennis Ritchie creates C at Bell Labs to rewrite the Unix operating system kernel.",
    "1972-01-02: Alan Kay, Dan Ingalls, and Adele Goldberg release Smalltalk, pioneering object-oriented UI design.",
    "1972-01-03: Alain Colmerauer and Philippe Roussel create Prolog, pioneering logic programming.",
    "1980-01-01: US Dept of Defense sponsors Ada, created by Jean Ichbiah for high-integrity embedded systems.",
    "1981-08-12: Microsoft releases MS-DOS 1.0 (PC DOS 1.0) alongside the original IBM PC Model 5150.",
    "1984-01-01: Brad Cox and Tom Love release Objective-C, combining Smalltalk object messaging with C syntax.",
    "1984-01-02: Cleve Moler releases MATLAB, designed for numeric computing, matrix math, and data visualization.",
    "1985-10-14: Bjarne Stroustrup releases C++ 1.0, adding object-oriented features to the C language.",
    "1985-11-20: Microsoft launches Windows 1.0, its first graphical GUI environment layer for MS-DOS.",
    "1987-12-18: Larry Wall releases Perl 1.0, a powerful text processing and system administration language.",
    "1990-04-01: A committee defines Haskell 1.0, a purely functional programming language named after Curry.",
    "1990-09-03: Scream Tracker 2.21 released by Sami Tammilehto, a.k.a Psi of Future Crew.",
    "1991-02-20: Guido van Rossum publishes Python 0.9.0, focusing on code readability and clean syntax.",
    "1991-05-01: Microsoft releases Visual Basic 1.0, revolutionizing rapid application GUI development.",
    "1991-09-17: Linus Torvalds uploads the first Linux kernel (version 0.01) to an FTP server.",
    "1992-04-06: Microsoft releases Windows 3.1, adding TrueType fonts, multimedia support, and 32-bit disk access.",
    "1993-01-01: Roberto Ierusalimschy et al. release Lua, a lightweight embeddable scripting language.",
    "1993-07-17: Patrick Volkerding releases Slackware 1.00, the oldest actively maintained Linux distro.",
    "1993-08-01: Ian Murdock releases Debian 0.90, laying the groundwork for one of Linux's largest distros.",
    "1993-08-01: Ross Ihaka and Robert Gentleman release R, designed for statistical computing and graphics.",
    "1994-11-03: Red Hat Linux 1.0 ('Mother's Day') is released, laying the foundation for RHEL.",
    "1994-12-06: Scream Tracker 3.21 released by Sami Tammilehto, a.k.a Psi of Future Crew.",
    "1995-06-08: Rasmus Lerdorf releases PHP, initially designed as Personal Home Page Tools for dynamic web.",
    "1995-08-24: Microsoft releases Windows 95, introducing the modern Start menu, Taskbar, and Explorer UI.",
    "1995-12-04: Netscape and Sun announce JavaScript, created in 10 days by Brendan Eich for browsers.",
    "1995-12-21: Yukihiro Matsumoto releases Ruby, designed for human programmer productivity and joy.",
    "1996-01-23: Sun Microsystems officially releases JDK 1.0 for Java ('Write Once, Run Anywhere').",
    "1998-06-25: Microsoft releases Windows 98, featuring improved USB support and Active Desktop integration.",
    "2001-10-25: Microsoft releases Windows XP, unifying its consumer and business lines under the NT kernel.",
    "2002-02-13: Microsoft releases C# 1.0 alongside the official launch of the .NET Framework 1.0.",
    "2002-03-11: Judd Vinet releases Arch Linux 0.1 ('Homer'), built on minimalism and user control.",
    "2003-11-06: Red Hat launches Fedora Core 1 ('Yarrow') as a community-driven upstream distribution.",
    "2004-01-20: Martin Odersky releases Scala, combining object-oriented and functional programming paradigms.",
    "2004-03-01: James Strachan releases Groovy, a dynamic agile language for the Java Virtual Machine.",
    "2004-10-20: Canonical releases Ubuntu 4.10 ('Warty Warthog'), bringing user-friendly Linux to the masses.",
    "2005-01-01: Don Syme releases F# at Microsoft Research, bringing functional programming to .NET.",
    "2007-01-30: Microsoft launches Windows Vista with Aero UI, User Account Control (UAC), and security updates.",
    "2009-10-22: Microsoft releases Windows 7, refining UI performance and introducing the modernized Taskbar.",
    "2009-11-10: Google announces Go as an open-source system language built for modern concurrent scale.",
    "2011-07-19: JetBrains reveals Kotlin, an expressive, concise JVM language later adopted for Android.",
    "2011-10-10: Google announces Dart as a client-optimized programming language for multi-platform apps.",
    "2012-01-01: José Valim creates Elixir, a dynamic functional language built on the Erlang VM (BEAM).",
    "2012-02-14: Bezanson, Edelman, Karpinski, and Shah release Julia for high-performance scientific computing.",
    "2012-10-01: Microsoft releases TypeScript 0.8, bringing static typing and tooling to JavaScript.",
    "2014-09-09: Apple releases Swift 1.0 with the iOS 8 SDK as a safe, fast replacement for Objective-C.",
    "2015-05-15: Mozilla releases Rust 1.0, guaranteeing memory safety without needing a garbage collector.",
    "2015-07-29: Microsoft launches Windows 10, introducing Windows as a Service and unified desktop UI.",
    "2016-02-08: Andrew Kelley creates Zig, a modern systems programming language designed to replace C.",
    "2021-10-05: Microsoft releases Windows 11 with a centered taskbar design and strict TPM 2.0 requirements.",
    "1969-09-13: Scooby-Doo, Where Are You! premiered on CBS.",
    "1969: Scooby-Doo was created by Joe Ruby and Ken Spears for Hanna-Barbera, with development overseen by exec.Fred Silverman.",
    "1969: Iwao Takamoto designed the main character Scooby-Doo and established his distinctive appearance.",
    "1969: Iwao Takamoto designed the visual appearances of Daphne Blake, Velma Dinkley, Fred Jones, and Shaggy Rogers.",
    "1969: The Mystery Machine first appeared as the team's iconic van in Scooby-Doo, Where Are You!.",
    "1969: The Mystery Machine was designed by Hanna-Barbera's production artists as part of the OG series.",
    "1969-09-13: Daphne Blake made her first appearance in the premiere episode of Scooby-Doo, Where Are You!.",
    "1969-09-13: Velma Dinkley made her first appearance in the premiere episode of Scooby-Doo, Where Are You!.",
    "1969-09-13: Fred Jones made his first appearance in the premiere episode of Scooby-Doo, Where Are You!.",
    "1969-09-13: Shaggy Rogers made his first appearance in the premiere episode of Scooby-Doo, Where Are You!.",
    "1969-09-13: Scooby-Doo made his first appearance in the premiere episode of Scooby-Doo, Where Are You!.",
    "1984-03-01 Clive Barker: Books of Blood Vol. 1 book published",
    "1985-11-01 Clive Barker: The Damnation Game book published",
    "1986-11-01 Clive Barker: The Hellbound Heart book published",
    "1987-05-01 Clive Barker: Weaveworld book published",
    "1988-09-02 Clive Barker: Hellraiser movie released",
    "1988-11-01 Clive Barker: Cabal book published",
    "1989-10-01 Clive Barker: The Great and Secret Show book published",
    "1990-02-16 Clive Barker: Nightbreed movie released",
    "1991-10-01 Clive Barker: Imajica book published",
    "1992-05-01 Clive Barker: Candyman movie released",
    "1994-10-01 Clive Barker: Everville book published",
    "1995-10-01 Clive Barker: Sacrament book published",
    "1996-10-01 Clive Barker: The Thief of Always book published",
    "1998-10-01 Clive Barker: Galilee book published",
    "2001-10-01 Clive Barker: Coldheart Canyon book published",
    "2002-10-11 Clive Barker: Saint Sinner movie released",
    "2004-10-01 Clive Barker: Abarat: Days of Magic, Nights of War book published",
    "2007-10-01 Clive Barker: Mister B. Gone book published",
    "2009-10-01 Clive Barker: Abarat: Absolute Midnight book published",
    "2009-10-23 Clive Barker: Book of Blood movie released",
    "2015-05-19 Clive Barker: The Scarlet Gospels book published",
    "2020-09-02 Clive Barker: Books of Blood movie released",
    "1935-01-08: Elvis Aaron Presley was born in Tupelo, Mississippi.",
    "1948-11-06: Elvis moved with his family to Memphis, Tennessee, shaping his musical influences.",
    "1953-07-18: Elvis recorded his first demo at Sun Studio as a gift for his mother.",
    "1954-07-05: Elvis recorded 'That's All Right,' launching his professional music career.",
    "1955-11-21: Elvis signed with RCA Records, greatly expanding his national fame.",
    "1956-01-01: Carl Perkins released 'Blue Suede Shoes', a major early rockabilly hit.",
    "1956-01-27: Elvis released 'Heartbreak Hotel,' his first No. 1 pop hit.",
    "1956-01-30: Elvis recorded 'Blue Suede Shoes' at RCA Studios in New York.",
    "1956-03-23: Elvis released debut album 'Elvis Presley' featuring 'Blue Suede Shoes.'",
    "1956-03-26: Elvis began filming his first movie, 'Love Me Tender.'",
    "1956-08-31: RCA released Elvis' 'Blue Suede Shoes' single version.",
    "1957-03-25: Elvis purchased Graceland mansion in Memphis.",
    "1958-03-24: Elvis entered the U.S. Army and served in Germany.",
    "1967-05-01: Elvis married Priscilla Beaulieu in Las Vegas.",
    "1977-08-16: Elvis Presley died at Graceland in Memphis at age 42.",
    "1992-05-05: id Software released Wolfenstein 3D",
    "1992-09-18: id Software released Spear of Destiny",
    "2001-11-19: id Software released Return to Castle Wolfenstein",
    "2003-05-29: id Software released Wolfenstein: Enemy Territory",
    "2009-08-18: id Software released Wolfenstein",
    "2014-05-20: id Software released Wolfenstein: The New Order",
    "2015-05-05: id Software released Wolfenstein: The Old Blood",
    "2017-10-27: id Software released Wolfenstein II: The New Colossus",
    "2019-07-26: id Software released Wolfenstein: Youngblood",
    "2019-07-26: id Software released Wolfenstein: Cyberpilot",
    "1993-12-10: id Software released Doom",
    "1994-09-30: id Software released Doom II: Hell on Earth",
    "1995-04-30: id Software released The Ultimate Doom",
    "1996-06-17: id Software released Final Doom",
    "1997-03-31: id Software released Doom 64",
    "2004-08-03: id Software released Doom 3",
    "2005-04-03: id Software released Doom 3: Resurrection of Evil",
    "2016-05-13: id Software released Doom 1",
    "2020-03-20: id Software released Doom Eternal",
    "2025-05-15: id Software released Doom: The Dark Ages",
    "1996-06-22: id Software released Quake",
    "1997-12-09: id Software released Quake II",
    "1999-12-02: id Software released Quake III Arena",
    "2005-10-18: id Software released Quake 4",
    "2007-09-28: id Software released Enemy Territory: Quake Wars",
    "2010-08-06: id Software released Quake Live",
    "2017-08-23: id Software released Quake Champions",
    "1988-10-01: id Software released Dangerous Dave in the Deserted Pirate's Hideout",
    "1990-01-01: id Software released Dangerous Dave (DOS)",
    "1991-02-01: id Software released Dangerous Dave in the Haunted Mansion",
    "1993-01-01: id Software released Dangerous Dave's Risky Rescue",
    "1993-01-01: id Software released Dave Goes Nutz!",
    "1986-01-01: Opera Soft S.A. released Cosa Nostra",
    "1986-11-01: Opera Soft S.A. released Livingstone Supongo",
    "1987-03-01: Opera Soft S.A. released The Last Mission",
    "1987-11-01: Opera Soft S.A. released La Abadía del Crimen",
    "1987-12-01: Opera Soft S.A. released Goody (ONE OF THE VERY BEST AND FINEST PLATFORMER GAMES EVER CREATED)",
    "1988-06-01: Opera Soft S.A. released Sol Negro",
    "1989-01-01: Opera Soft S.A. released Mutan Zone",
    "1989-03-01: Opera Soft S.A. released Corsarios",
    "1989-09-01: Opera Soft S.A. released Mot",
    "1989-11-01: Opera Soft S.A. released Livingstone Supongo II",
    "1990-01-01: Opera Soft S.A. released Soviet",
    "1990-05-01: Opera Soft S.A. released Rescate en el Golfo",
    "1986: Pedro Ruiz, Paco Suárez, José Maqueira, Carlos de Castro and José Ortega found Opera Soft S.A. in Madrid, Spain.",
    "1991-02-01: John Carmack, John Romero, Tom Hall and Adrian Carmack found id Software in Shreveport, Louisiana (USA).",
    "1996-02-27: Game Freak released Pokémon Red and Green",
    "1996-10-15: Game Freak released Pokémon Blue",
    "1998-09-12: Game Freak released Pokémon Yellow",
    "1999-11-21: Game Freak released Pokémon Gold and Silver",
    "2000-12-14: Game Freak released Pokémon Crystal",
    "2002-11-21: Game Freak released Pokémon Ruby and Sapphire",
    "2004-01-29: Game Freak released Pokémon FireRed and LeafGreen",
    "2004-09-16: Game Freak released Pokémon Emerald",
    "2006-09-28: Game Freak released Pokémon Diamond and Pearl",
    "2008-09-13: Game Freak released Pokémon Platinum",
    "2009-09-12: Game Freak released Pokémon HeartGold and SoulSilver",
    "2010-09-18: Game Freak released Pokémon Black and White",
    "2012-06-23: Game Freak released Pokémon Black 2 and White 2",
    "2013-10-12: Game Freak released Pokémon X and Y",
    "2014-11-21: Game Freak released Pokémon Omega Ruby and Alpha Sapphire",
    "2016-11-18: Game Freak released Pokémon Sun and Moon",
    "2017-11-17: Game Freak released Pokémon Ultra Sun and Ultra Moon",
    "2018-11-16: Game Freak released Pokémon Let's Go, Pikachu! and Let's Go, Eevee!",
    "2019-11-15: Game Freak released Pokémon Sword and Shield",
    "2021-11-19: ILCA released Pokémon Brilliant Diamond and Shining Pearl",
    "2022-01-28: Game Freak released Pokémon Legends: Arceus",
    "2022-11-18: Game Freak released Pokémon Scarlet and Violet",
    "1998-09-08: 'Pokémon-I Choose You!' pilot episode aired in US, making Veronica Taylor the most adored voice actor in TV history.",
    "2004-08-18: Oni Press released Scott Pilgrim's Precious Little Life",
    "2005-02-16: Oni Press released Scott Pilgrim vs. The World",
    "2006-05-24: Oni Press released Scott Pilgrim & The Infinite Sadness",
    "2007-11-14: Oni Press released Scott Pilgrim Gets It Together",
    "2009-02-04: Oni Press released Scott Pilgrim vs. The Universe",
    "2010-07-20: Oni Press released Scott Pilgrim's Finest Hour",
    "2010-07-27: Adult Swim released Scott Pilgrim vs. The Animation",
    "2010-08-13: Universal Pictures released Scott Pilgrim vs. the World",
    "2010-08-10: Ubisoft released Scott Pilgrim vs. the World: The Game",
    "2026-03-02: Tribute Games released Scott Pilgrim EX game",
    "1977-09-11: Atari released Atari 2600",
    "1982-11-01: Atari released Atari 5200",
    "1983-07-15: Nintendo released Family Computer (Famicom)",
    "1985-10-18: Nintendo released Nintendo Entertainment System (NES)",
    "1985-10-20: Sega released Master System",
    "1986-05-01: Atari released Atari 7800",
    "1988-10-29: Sega released Sega Genesis / Mega Drive",
    "1989-04-21: Nintendo released Game Boy",
    "1990-10-06: Sega released Game Gear",
    "1990-11-21: Nintendo released Super Nintendo Entertainment System (SNES)",
    "1994-12-03: Sony released PlayStation",
    "1998-10-21: Nintendo released Game Boy Color",
    "2000-03-04: Sony released PlayStation 2",
    "2001-03-21: Nintendo released Game Boy Advance",
    "2001-11-15: Microsoft released Xbox",
    "2004-11-21: Nintendo released Nintendo DS",
    "2004-12-12: Sony released PlayStation Portable",
    "2005-11-22: Microsoft released Xbox 360",
    "2006-11-11: Sony released PlayStation 3",
    "2006-11-19: Nintendo released Wii",
    "2011-02-26: Nintendo released Nintendo 3DS",
    "2011-12-17: Sony released PlayStation Vita",
    "2012-11-18: Nintendo released Wii U",
    "2013-11-15: Sony released PlayStation 4",
    "2013-11-22: Microsoft released Xbox One",
    "2017-03-03: Nintendo released Nintendo Switch",
    "2020-11-12: Sony released PlayStation 5",
    "2022-02-25: Valve released Steam Deck",
    "1980-05-18: Joy Division frontman Ian Curtis passed away",
    "1979-06-15: Joy Division released Unknown Pleasures LP",
    "1980-07-18: Joy Division released Closer LP",
]

NON_AGRICULTURAL_ZONES: list[tuple[float, float, float, float]] = [
    # 1. Antarctica
    (-90.0, -60.0, -180.0, 180.0),
    # 2. Greenland Interior & High Arctic Cap
    (72.0, 90.0, -180.0, 180.0),
    # 3. Core Sahara Desert
    (18.0, 27.0, -8.0, 28.0),
    # 4. Rub' al Khali (Empty Quarter - Arabian Peninsula)
    (17.0, 24.0, 45.0, 55.0),
    # 5. Core Atacama Desert
    (-25.0, -18.0, -70.5, -68.0),
    # 6. Tibetan Plateau (High Elevation Zone)
    (30.0, 36.0, 80.0, 95.0),
    # 7. Taklamakan Desert Core
    (37.0, 41.0, 78.0, 88.0),
]

SEASONAL_DATA: dict[str, dict[int, dict[str, list[str]]]] = {
    "mediterranean": {
        1: {
            "veggies": ["Cabbage", "Kale", "Leeks", "Carrots", "Spinach"],
            "fruits": ["Oranges", "Mandarins", "Lemons", "Grapefruit"],
        },
        2: {
            "veggies": ["Broccoli", "Cauliflower", "Radishes", "Chard"],
            "fruits": ["Oranges", "Mandarins", "Lemons"],
        },
        3: {
            "veggies": ["Asparagus", "Artichokes", "Spring Onions", "Peas"],
            "fruits": ["Strawberries", "Lemons"],
        },
        4: {
            "veggies": ["Asparagus", "Broad Beans", "Lettuce", "Radishes"],
            "fruits": ["Strawberries", "Loquats"],
        },
        5: {
            "veggies": ["Zucchini", "Cucumbers", "Green Beans", "Fennel"],
            "fruits": ["Cherries", "Apricots", "Strawberries"],
        },
        6: {
            "veggies": ["Tomatoes", "Zucchini", "Cucumbers", "Bell Peppers"],
            "fruits": ["Peaches", "Nectarines", "Cherries", "Apricots"],
        },
        7: {
            "veggies": [
                "Pink Tomatoes",
                "Cucumbers",
                "Bell Peppers",
                "Eggplants",
                "Zucchini",
            ],
            "fruits": ["Watermelon", "Melon", "Peaches", "Plums", "Figs"],
        },
        8: {
            "veggies": [
                "Tomatoes",
                "Red Kapiya Peppers",
                "Eggplants",
                "Sweet Corn",
                "Okra",
            ],
            "fruits": ["Watermelon", "Figs", "Peaches", "Table Grapes", "Blackberries"],
        },
        9: {
            "veggies": [
                "Roasting Peppers",
                "Tomatoes",
                "Pumpkins",
                "Green Beans",
            ],
            "fruits": ["Grapes", "Fresh Figs", "Plums", "Pears", "Apples"],
        },
        10: {
            "veggies": ["Squash", "Pumpkins", "Beetroot", "Cabbage", "Carrots"],
            "fruits": ["Apples", "Pears", "Pomegranates", "Persimmons", "Quince"],
        },
        11: {
            "veggies": ["Cauliflower", "Broccoli", "Spinach", "Turnips"],
            "fruits": ["Pomegranates", "Persimmons", "Oranges", "Clementines"],
        },
        12: {
            "veggies": ["Brussels Sprouts", "Cabbage", "Leeks", "Parsnips"],
            "fruits": ["Oranges", "Mandarins", "Lemons", "Grapefruit"],
        },
    },
    "temperate": {
        1: {
            "veggies": ["Parsnips", "Carrots", "Kale", "Leeks", "Potatoes"],
            "fruits": ["Stored Apples", "Stored Pears"],
        },
        2: {
            "veggies": ["Brussels Sprouts", "Cabbage", "Parsnips", "Turnips"],
            "fruits": ["Stored Apples"],
        },
        3: {
            "veggies": ["Forced Rhubarb", "Kale", "Purple Sprouting Broccoli"],
            "fruits": ["Stored Apples"],
        },
        4: {
            "veggies": ["Asparagus", "Rhubarb", "Radishes", "Spring Lettuce"],
            "fruits": ["Rhubarb"],
        },
        5: {
            "veggies": ["Asparagus", "Spinach", "Radishes", "Lettuce", "Peas"],
            "fruits": ["Strawberries"],
        },
        6: {
            "veggies": [
                "Broad Beans",
                "Peas",
                "New Potatoes",
                "Zucchini",
                "Lettuce",
            ],
            "fruits": ["Strawberries", "Gooseberries", "Cherries"],
        },
        7: {
            "veggies": ["Tomatoes", "Cucumbers", "Green Beans", "Sweet Corn"],
            "fruits": [
                "Raspberries",
                "Blackberries",
                "Blueberries",
                "Cherries",
            ],
        },
        8: {
            "veggies": [
                "Sweet Corn",
                "Tomatoes",
                "Zucchini",
                "Beetroot",
                "Carrots",
            ],
            "fruits": ["Plums", "Peaches", "Blackberries", "Early Apples"],
        },
        9: {
            "veggies": ["Pumpkins", "Squash", "Carrots", "Onions", "Leeks"],
            "fruits": ["Apples", "Pears", "Plums", "Grapes"],
        },
        10: {
            "veggies": [
                "Pumpkins",
                "Winter Squash",
                "Wild Mushrooms",
                "Parsnips",
            ],
            "fruits": ["Apples", "Pears", "Cranberries"],
        },
        11: {
            "veggies": ["Kale", "Leeks", "Brussels Sprouts", "Turnips"],
            "fruits": ["Apples", "Pears"],
        },
        12: {
            "veggies": ["Parsnips", "Cabbage", "Carrots", "Leeks"],
            "fruits": ["Stored Apples"],
        },
    },
    "tropical": {
        # Equatorial region produce seasonal availability is driven mainly by wet/dry cycles
        # Default harvest profile across equatorial zones:
        month: {
            "veggies": [
                "Cassava",
                "Sweet Potatoes",
                "Taro Leaf",
                "Okra",
                "Watercress",
            ],
            "fruits": [
                "Bananas",
                "Papaya",
                "Mangoes",
                "Pineapple",
                "Passion Fruit",
                "Coconuts",
            ],
        }
        for month in range(1, 13)
    },
    "arid_semiarid": {
        # Dry zones (Middle East / North Africa / inland basins)
        1: {
            "veggies": ["Onions", "Garlic", "Cabbage"],
            "fruits": ["Dates", "Citrus"],
        },
        2: {
            "veggies": ["Carrots", "Radishes", "Spinach"],
            "fruits": ["Citrus"],
        },
        3: {
            "veggies": ["Fava Beans", "Peas"],
            "fruits": ["Citrus", "Strawberries"],
        },
        4: {
            "veggies": ["Cucumbers", "Zucchini"],
            "fruits": ["Strawberries", "Early Melons"],
        },
        5: {
            "veggies": ["Tomatoes", "Eggplants"],
            "fruits": ["Melons", "Watermelon", "Apricots"],
        },
        6: {
            "veggies": ["Peppers", "Eggplants", "Okra"],
            "fruits": ["Watermelon", "Figs", "Grapes"],
        },
        7: {
            "veggies": ["Okra", "Peppers", "Squash"],
            "fruits": ["Dates", "Watermelon", "Figs"],
        },
        8: {
            "veggies": ["Eggplants", "Peppers"],
            "fruits": ["Fresh Dates", "Figs", "Grapes"],
        },
        9: {
            "veggies": ["Tomatoes", "Cucumbers"],
            "fruits": ["Dates", "Pomegranates"],
        },
        10: {
            "veggies": ["Squash", "Onions"],
            "fruits": ["Pomegranates", "Citrus"],
        },
        11: {
            "veggies": ["Spinach", "Radishes"],
            "fruits": ["Citrus", "Olives"],
        },
        12: {
            "veggies": ["Leeks", "Carrots"],
            "fruits": ["Citrus", "Dates"],
        },
    },
}

HEALTH_MOTIVATION: list[str] = [
    "Did you hit your 10,000 steps today?",
    "Drink a full glass of water right now to instantly boost your energy.",
    "Take 5 deep breaths — let your shoulders drop and relax your posture.",
    "Choose the stairs over the elevator for a quick mini-workout.",
    "Stretch for just two minutes to wake up stiff muscles and improve blood flow.",
    "Get outside for 10 minutes of fresh air and natural sunlight today.",
    "Swap one sugary snack for a fresh piece of seasonal fruit.",
    "Stand up and move around for two minutes every hour you sit at your desk.",
    "Give your eyes a screen break: look 20 feet away for 20 seconds.",
    "Aim for 7 to 8 hours of restful sleep tonight to let your body recover.",
]

# fmt: off
POLAR_STATIONS: list[list[str]] = [
    ["Amundsen–Scott South Pole STN", "USA", "-90.0", "0.0", "AN", "Antarctica/South_Pole", "US"],
    ["Concordia STN", "France/Italy", "-75.1", "123.3333", "AN", "Antarctica/McMurdo", "FR"],
    ["Vostok STN", "Russia", "-78.4667", "106.8", "AN", "Antarctica/Vostok", "RU"],
    ["Dome Fuji STN", "Japan", "-77.3167", "39.7", "AN", "Antarctica/Syowa", "JP"],
    ["Kunlun STN", "China", "-80.4167", "77.1167", "AN", "Antarctica/Mawson", "CN"],
    ["McMurdo STN", "USA", "-77.85", "166.6667", "AN", "Antarctica/McMurdo", "US"],
    ["Scott STN", "New Zealand", "-77.85", "166.7667", "AN", "Antarctica/McMurdo", "NZ"],
    ["Casey STN", "Australia", "-66.2833", "110.5167", "AN", "Antarctica/Casey", "AU"],
    ["Davis STN", "Australia", "-68.5833", "77.9667", "AN", "Antarctica/Davis", "AU"],
    ["Mawson STN", "Australia", "-67.6", "62.8667", "AN", "Antarctica/Mawson", "AU"],
    ["Zhongshan STN", "China", "-69.3667", "76.3667", "AN", "Antarctica/Mawson", "CN"],
    ["Taishan STN", "China", "-73.85", "76.9667", "AN", "Antarctica/Mawson", "CN"],
    ["Qinling STN", "China", "-74.9333", "163.7", "AN", "Antarctica/McMurdo", "CN"],
    ["Bharati STN", "India", "-69.4", "76.1833", "AN", "Antarctica/Mawson", "IN"],
    ["Maitri STN", "India", "-70.7667", "11.7333", "AN", "Antarctica/Syowa", "IN"],
    ["Novolazarevskaya STN", "Russia", "-70.7667", "11.8333", "AN", "Antarctica/Syowa", "RU"],
    ["Progress STN", "Russia", "-69.3667", "76.3833", "AN", "Antarctica/Mawson", "RU"],
    ["Mirny STN", "Russia", "-66.55", "93.0167", "AN", "Antarctica/Vostok", "RU"],
    ["Showa (Syowa) STN", "Japan", "-69.0", "39.5833", "AN", "Antarctica/Syowa", "JP"],
    ["Jang Bogo STN", "South Korea", "-74.6167", "164.2", "AN", "Antarctica/McMurdo", "KR"],
    ["Dumont d'Urville STN", "France", "-66.6667", "140.0167", "AN", "Antarctica/DumontDUrville", "FR"],
    ["Princess Elisabeth STN", "Belgium", "-71.95", "23.3333", "AN", "Antarctica/Syowa", "BE"],
    ["Halley VI Research STN", "UK", "-75.5833", "-26.5667", "AN", "Antarctica/Rothera", "GB"],
    ["Neumayer III STN", "Germany", "-70.6667", "-8.2667", "AN", "UTC", "DE"],
    ["SANAE IV STN", "South Africa", "-71.6667", "-2.8333", "AN", "Africa/Johannesburg", "ZA"],
    ["Belgrano II STN", "Argentina", "-77.8667", "-34.6167", "AN", "America/Argentina/Buenos_Aires", "AR"],
    ["Troll STN", "Norway", "-72.0167", "2.5333", "AN", "Antarctica/Troll", "NO"],
    ["Palmer STN", "USA", "-64.7667", "-64.05", "AN", "Antarctica/Palmer", "US"],
    ["Rothera Research STN", "UK", "-67.5667", "-68.1333", "AN", "Antarctica/Rothera", "GB"],
    ["Esperanza STN", "Argentina", "-63.4", "-56.9833", "AN", "America/Argentina/Buenos_Aires", "AR"],
    ["Marambio STN", "Argentina", "-64.2333", "-56.6167", "AN", "America/Argentina/Buenos_Aires", "AR"],
    ["Carlini STN", "Argentina", "-62.2333", "-58.6667", "AN", "America/Argentina/Buenos_Aires", "AR"],
    ["San Martín STN", "Argentina", "-68.1333", "-67.1", "AN", "America/Argentina/Buenos_Aires", "AR"],
    ["Gen.Bernardo O'Higgins STN", "Chile", "-63.3167", "-57.9", "AN", "America/Punta_Arenas", "CL"],
    ["Pres.Eduardo Frei Montalva STN", "Chile", "-62.2", "-58.9667", "AN", "America/Punta_Arenas", "CL"],
    ["Capt.Arturo Prat STN", "Chile", "-62.5", "-59.6833", "AN", "America/Punta_Arenas", "CL"],
    ["Great Wall STN", "China", "-62.2167", "-58.9667", "AN", "Antarctica/Rothera", "CN"],
    ["King Sejong STN", "South Korea", "-62.2167", "-58.7833", "AN", "Antarctica/Rothera", "KR"],
    ["Henryk Arctowski STN", "Poland", "-62.15", "-58.4667", "AN", "Antarctica/Rothera", "PL"],
    ["Comandante Ferraz STN", "Brazil", "-62.0833", "-58.3833", "AN", "Antarctica/Rothera", "BR"],
    ["Bellingshausen STN", "Russia", "-62.2", "-58.9667", "AN", "Antarctica/Rothera", "RU"],
    ["Vernadsky Research STN", "Ukraine", "-65.25", "-64.2667", "AN", "Antarctica/Rothera", "UA"],
    ["Artigas STN", "Uruguay", "-62.1833", "-58.9", "AN", "Antarctica/Rothera", "UY"],
    ["St.Kliment Ohridski STN", "Bulgaria", "-62.6333", "-60.35", "AN", "Antarctica/Rothera", "BG"],
    ["Juan Carlos I STN", "Spain", "-62.65", "-60.3833", "AN", "Antarctica/Rothera", "ES"],
    ["Gabriel de Castilla STN", "Spain", "-62.9833", "-60.6833", "AN", "Antarctica/Rothera", "ES"]
]
# fmt: on


# You probably have even more questions here.
# Me too.
def pick_one(stuff: list[str]) -> str:
    if not stuff:
        raise ValueError("Empty source given.")
    return random.choice(stuff)


def polar_stations(stnnum: int | None = None) -> list[str]:
    """If stnnum is not specified, returns list of all Polar Stations as a
    textual menu.

    Otherwise returns information about the requested Polar Station.
    """
    result = []
    if stnnum is None:
        for i, stn in enumerate(POLAR_STATIONS):
            name, country, lat, lon, continent, timezone, country_code2 = stn
            result.append(f"{i + 1}) {name}, {country}")
    else:
        if stnnum < 1 or stnnum > len(POLAR_STATIONS):
            raise ValueError(
                f"Invalid station number! Must be a number between 1 and {len(POLAR_STATIONS)}."
            )
        result = POLAR_STATIONS[stnnum - 1]
    return result


class ExitHandler:
    """Handles application exit with Ctrl-C etc."""

    @staticmethod
    def set_handler(handler: callable) -> None:
        # Standard terminal disconnect (some terminals)
        if hasattr(signal, "SIGHUP"):
            signal.signal(signal.SIGHUP, handler)

        # Sent by GTK terminals (Terminator, GNOME Terminal, xfce4-terminal) on 'X' click
        signal.signal(signal.SIGTERM, handler)

        # Ctrl+C
        signal.signal(signal.SIGINT, handler)

        # Windows console close
        if hasattr(signal, "SIGBREAK"):
            signal.signal(signal.SIGBREAK, handler)


class LocalProduceAdvisor:
    # 5-degree coarse grid mapping: (snapped_lat, snapped_lon) -> climate_zone
    COARSE_GRID: dict[tuple[int, int], str] = {
        (40, 25): "mediterranean",  # Balkans / Greece
        (45, 10): "mediterranean",  # N. Italy
        (50, 10): "temperate",  # Central Europe
        (25, 50): "arid_semiarid",  # Middle East
        (10, 105): "tropical",  # SE Asia
    }

    @staticmethod
    def _is_in_dead_zone(lat: float, lon: float) -> bool:
        """Check if coordinates fall inside any defined non-agricultural region."""
        for min_lat, max_lat, min_lon, max_lon in NON_AGRICULTURAL_ZONES:
            if min_lat <= lat <= max_lat and min_lon <= lon <= max_lon:
                return True
        return False

    @classmethod
    def get_zone(cls, lat: float, lon: float) -> str:
        snapped_lat = round(lat / 5.0) * 5
        snapped_lon = round(lon / 5.0) * 5
        return cls.COARSE_GRID.get((snapped_lat, snapped_lon), "temperate")

    @classmethod
    def get_seasonal_produce(
        cls, lat: float, lon: float, month: int | None = None
    ) -> dict[str, list[str]]:
        # Return empty produce dict for dead zones (e.g. Antarctica)
        if cls._is_in_dead_zone(lat, lon):
            return {}

        if month is None:
            month = datetime.now().month

        zone = cls.get_zone(lat, lon)

        # Invert months for Southern Hemisphere coordinates
        if lat < 0:
            month = (month + 5) % 12 + 1

        return SEASONAL_DATA.get(zone, {}).get(month, {"veggies": [], "fruits": []})


def on_terminal_close(signum, frame):
    # Optional: Log which signal actually caught the close event
    sig_name = signal.Signals(signum).name

    logger: logging.Logger = logging.getLogger(__name__)
    logger.info(f"Application closed with signal: {sig_name}")
    logger.info("===================================================== SESSION END")

    print(
        f"TUIWEATHERGIRL {__version__} -Weather and disaster station- by Evgueni Antonov (StrayF) 2026."
    )
    print("For help: tuiweathergirl --help")
    print("Cast Spells!")

    sys.exit(0)


class TerminalSize:
    """Defines a terminal/tty size.

    Initially a view's size was independent, but I decided to tie it to the
    terminal size for which it was created.
    """

    def __init__(self, **kwargs: list[int]) -> None:
        self.rows: int = kwargs["rows"]
        self.columns: int = kwargs["columns"]


class DefaultTerminal(TerminalSize):
    """My own terminal size"""

    def __init__(self) -> None:
        super().__init__(
            **{
                "rows": 38,
                "columns": 146,
            }
        )


class TTYTerminal(TerminalSize):
    """Barebone linux virtual console (TTY) size"""

    def __init__(self) -> None:
        super().__init__(
            **{
                "rows": 33,
                "columns": 106,
            }
        )


class Theme:
    def __init__(self, **kwargs: list[int]) -> None:
        # Interface elements
        self.general: list[int] = kwargs["general"]
        self.border: list[int] = kwargs["border"]
        self.header: list[int] = kwargs["header"]
        self.home: list[int] = kwargs["home"]
        self.title: list[int] = kwargs["title"]
        self.warntitle: list[int] = kwargs["warntitle"]
        self.warnborder: list[int] = kwargs["warnborder"]

        # Text elements
        self.regular: list[int] = kwargs["regular"]
        self.normal: list[int] = kwargs["normal"]
        self.water: list[int] = kwargs["water"]
        self.good: list[int] = kwargs["good"]
        self.important: list[int] = kwargs["important"]
        self.error: list[int] = kwargs["error"]
        self.emergency: list[int] = kwargs["emergency"]

        # Misc
        self._last_used: int | None = None
        self._last_window: curses.window | None = None
        self._last_dim: bool = False

    def on(self, win: curses.window, attr: str | int | None, **kwargs) -> None:
        if attr is None:
            attr = "general"

        dim: bool = kwargs.get("dim", False)
        cp: str | int | None = attr

        if isinstance(attr, str):
            if not hasattr(self, attr):
                raise ValueError(f"Theme object does not have attribute '{attr}'.")
            color = getattr(self, attr)
            cp, dim = color

        attrs: int = curses.color_pair(cp)
        if dim:
            attrs |= curses.A_DIM
        win.attron(attrs)

        self._last_used = cp
        self._last_window = win
        self._last_dim = dim

    def off(self) -> None:
        if self._last_dim:
            self._last_window.attroff(curses.color_pair(self._last_used) | curses.A_DIM)
            return
        self._last_window.attroff(curses.color_pair(self._last_used))


class MainTheme(Theme):
    def __init__(self) -> None:
        super().__init__(
            **{
                "general": [COL_WHITEBLACK, True],
                "border": [COL_BLUEBLACK, True],
                "header": [COL_WHITEBLACK, True],
                "home": [COL_WHITEBLACK, False],
                "title": [COL_CYANBLACK, False],
                "warntitle": [COL_WHITEBLACK, False],
                "warnborder": [COL_REDBLACK, False],
                "regular": [COL_BLUEBLACK, False],
                "normal": [COL_WHITEBLACK, False],
                "water": [COL_CYANBLACK, False],
                "good": [COL_GREENBLACK, False],
                "important": [COL_YELLOWBLACK, False],
                "error": [COL_REDBLACK, False],
                "emergency": [COL_YELLOWRED, False],
            }
        )


class MonoTheme(Theme):
    def __init__(self) -> None:
        super().__init__(
            **{
                "general": [COL_WHITEBLACK, True],
                "border": [COL_WHITEBLACK, True],
                "header": [COL_WHITEBLACK, True],
                "home": [COL_WHITEBLACK, True],
                "title": [COL_WHITEBLACK, True],
                "warntitle": [COL_WHITEBLACK, False],
                "warnborder": [COL_WHITEBLACK, False],
                "regular": [COL_WHITEBLACK, True],
                "normal": [COL_WHITEBLACK, True],
                "water": [COL_WHITEBLACK, True],
                "good": [COL_WHITEBLACK, True],
                "important": [COL_WHITEBLACK, False],
                "error": [COL_WHITEBLACK, False],
                "emergency": [COL_WHITEBLACK, False],
            }
        )


class LemonTheme(Theme):
    def __init__(self) -> None:
        super().__init__(
            **{
                "general": [COL_WHITEBLACK, True],
                "border": [COL_WHITEBLACK, True],
                "header": [COL_WHITEBLACK, True],
                "home": [COL_WHITEBLACK, True],
                "title": [COL_WHITEBLACK, True],
                "warntitle": [COL_WHITEBLACK, False],
                "warnborder": [COL_WHITEBLACK, False],
                "regular": [COL_YELLOWBLACK, True],
                "normal": [COL_YELLOWBLACK, True],
                "water": [COL_YELLOWBLACK, True],
                "good": [COL_YELLOWBLACK, True],
                "important": [COL_YELLOWBLACK, False],
                "error": [COL_YELLOWBLACK, False],
                "emergency": [COL_YELLOWBLACK, False],
            }
        )


class ArcticTheme(Theme):
    def __init__(self) -> None:
        super().__init__(
            **{
                "general": [COL_CYANBLACK, True],
                "border": [COL_CYANBLACK, True],
                "header": [COL_CYANBLACK, True],
                "home": [COL_CYANBLACK, True],
                "title": [COL_WHITEBLACK, True],
                "warntitle": [COL_CYANBLACK, False],
                "warnborder": [COL_CYANBLACK, False],
                "regular": [COL_YELLOWBLACK, True],
                "normal": [COL_CYANBLACK, False],
                "water": [COL_CYANBLACK, False],
                "good": [COL_CYANBLACK, False],
                "important": [COL_YELLOWBLACK, False],
                "error": [COL_YELLOWBLACK, False],
                "emergency": [COL_YELLOWRED, False],
            }
        )


class ThemePalette:
    def __init__(self) -> None:
        self.palette: dict[str, Theme] = {
            "main": MainTheme(),
            "mono": MonoTheme(),
            "lemon": LemonTheme(),
            "arctic": ArcticTheme(),
        }
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

    def get_theme(self, themename: str = "main") -> Theme:
        if themename not in self.palette:
            raise ValueError(f"Invalid theme name '{themename}'.")
        self.logger.info(f"Theme: '{themename}' is now set")
        return self.palette[themename]

    def init_colors(self) -> None:
        # Color definitions
        curses.start_color()
        curses.init_pair(
            COL_YELLOWRED, curses.COLOR_YELLOW, curses.COLOR_RED
        )  # Warnings
        curses.init_pair(
            COL_YELLOWBLACK, curses.COLOR_YELLOW, curses.COLOR_BLACK
        )  # Sunny
        curses.init_pair(
            COL_WHITEBLACK, curses.COLOR_WHITE, curses.COLOR_BLACK
        )  # Cloudy
        curses.init_pair(COL_BLUEBLACK, curses.COLOR_BLUE, curses.COLOR_BLACK)  # Rain
        curses.init_pair(COL_REDBLACK, curses.COLOR_RED, curses.COLOR_BLACK)  # Bad
        curses.init_pair(COL_GREENBLACK, curses.COLOR_GREEN, curses.COLOR_BLACK)  # Good
        curses.init_pair(COL_CYANBLACK, curses.COLOR_CYAN, curses.COLOR_BLACK)  # Title


class TextFlowStyle:
    r"""Class to define the flow of text for Paragraphs, TextLists, etc.

    It defines the side indents and above and below spacings."""

    def __init__(self, **kwargs) -> None:
        self.align: str = kwargs.get("align", "left")
        self.left_indent: int = kwargs.get("leftindent", 0)
        self.right_indent: int = kwargs.get("rightindent", 0)
        self.above_spacing: int = kwargs.get("abovespacing", 0)
        self.below_spacing: int = kwargs.get("belowspacing", 0)
        self.blockquote_indent: int = kwargs.get("blockquote", 0)
        self.bullet_space: int = kwargs.get("bulletspace", 1)

        # Yeah, I know. But I do sometimes make this typo.
        if "bellow" in kwargs:
            raise ValueError(
                "Syntax error. Passed argument 'beLLowspacing', instead of 'beLowspacing'."
            )


class MainTextStyle(TextFlowStyle):
    def __init__(self) -> None:
        super().__init__(
            **{
                "align": "left",
                "leftindent": 0,
                "rightindent": 0,
                "abovespacing": 0,
                "belowspacing": 0,
                "blockquote": 4,
                "bulletspace": 1,
            }
        )


class TextParagraph:
    r"""Defines a paragraph of text.

    Any passed argument overrides the TextFlowStyle definition."""

    def __init__(self, text: str, **kwargs) -> None:
        self._data: str = text
        self.style: TextFlowStyle = kwargs.get("flowstyle", MainTextStyle())
        self.left_indent: int = kwargs.get("leftindent", self.style.left_indent)
        self.right_indent: int = kwargs.get("rightindent", self.style.right_indent)
        self.above_spacing: int = kwargs.get("abovespacing", self.style.above_spacing)
        self.below_spacing: int = kwargs.get("belowspacing", self.style.below_spacing)
        self.first_line: int = kwargs.get("firstline", 0)  # Indent

        self._wrapper: textwrap.TextWrapper = textwrap.TextWrapper(
            expand_tabs=True,
            replace_whitespace=True,
            drop_whitespace=True,
            break_long_words=False,
            break_on_hyphens=False,
            initial_indent=" " * self.first_line,
            subsequent_indent=" " * self.left_indent,
        )

    def get_data(self, width: int = 0) -> list[str]:
        """Wraps the paragraph text into a list of strings, where each string's
        length is less than or equal to the desired width. Splits strictly by spaces.
        """

        if not self._data.strip():
            return []

        if width <= 0:
            return [self._data]

        actual_width: int = width - self.right_indent
        if actual_width <= 0:
            raise ValueError(f"Available layout width ({actual_width}) is too small.")

        self._wrapper.width = actual_width
        lines: list[str] = self._wrapper.wrap(self._data)

        for l in lines:
            if len(l) > width:
                raise ValueError(
                    f"Cannot wrap paragraph. Shortest line is longer than the required width of {width} characters."
                )

        return lines


class TextList:
    r"""Defines a list of text.

    Any passed argument overrides the TextFlowStyle definition."""

    def __init__(self, text: list[str], **kwargs) -> None:
        self._data: list[str] = text
        self.style: TextFlowStyle = kwargs.get("flowstyle", MainTextStyle())
        self.left_indent: int = kwargs.get("leftindent", self.style.left_indent)
        self.right_indent: int = kwargs.get("rightindent", self.style.right_indent)
        self.above_spacing: int = kwargs.get("abovespacing", self.style.above_spacing)
        self.below_spacing: int = kwargs.get("belowspacing", self.style.below_spacing)
        self.bullet: str = kwargs.get("bullet", "*")
        self.number_separator: str = kwargs.get("numberseparator", ".")
        self.ordered: bool = kwargs.get("ordered", False)
        self.bullet_space: int = kwargs.get("bulletspace", self.style.bullet_space)

        if len(self.bullet) > 1:
            raise ValueError("The bullet must be a one-character string.")

        first_line_prepend: str = self.number_separator + " " * self.bullet_space
        other_lines_prepend: str = " " * (
            len(first_line_prepend) + 2
        )  # Because of the number
        if not self.ordered:
            first_line_prepend = self.bullet + " " * self.bullet_space
            other_lines_prepend = " " * len(first_line_prepend)

        self._wrapper = textwrap.TextWrapper(
            expand_tabs=True,
            replace_whitespace=True,
            drop_whitespace=True,
            break_long_words=False,
            break_on_hyphens=False,
            initial_indent=first_line_prepend,
            subsequent_indent=other_lines_prepend,
        )

    def get_data(self, width: int = 0) -> list[str]:
        if not self._data:
            return []

        # generate the wrapped lines for each list item
        # if ordered: replace the bulletchar with a number+number_separator
        # append all item_lines into lines and return lines
        actual_width: int = width - self.right_indent
        if actual_width <= 0:
            raise ValueError(f"Available layout width ({actual_width}) is too small.")

        self._wrapper.width = actual_width
        i: int = 0
        lines: list[str] = []
        for item in self._data:
            i += 1
            item_lines: list[str] = self._wrapper.wrap(item)

            # Prepending the number if the list is ordered.
            # And no - my use cases involve only lists with up to 20 items,
            # so I do not imagine anyone would want to print an ordered list
            # of 500 elements
            if len(item_lines) and self.ordered:
                item_lines[0] = f"{i}{item_lines[0]}"

            for l in item_lines:
                if len(l) > width:
                    raise ValueError(
                        f"Cannot wrap paragraph. Shortest line is longer than the required width of {width} characters."
                    )

            lines.extend(item_lines)

        return lines


class BlockQuote(TextParagraph):
    def __init__(self, text: str, **kwargs) -> None:
        self._data: str = text
        self.style: TextFlowStyle = kwargs.get("flowstyle", MainTextStyle())
        self.left_indent: int = kwargs.get("leftindent", self.style.left_indent)
        self.right_indent: int = kwargs.get("rightindent", self.style.right_indent)
        self.above_spacing: int = kwargs.get("abovespacing", self.style.above_spacing)
        self.below_spacing: int = kwargs.get("belowspacing", self.style.below_spacing)
        self.quotechar: str = kwargs.get("quotechar", "│")

        self._wrapper = textwrap.TextWrapper(
            expand_tabs=True,
            replace_whitespace=True,
            drop_whitespace=True,
            break_long_words=False,
            break_on_hyphens=False,
            initial_indent=f"{self.quotechar} ",
            subsequent_indent=f"{self.quotechar} ",
        )


class Window:
    def vislen(self, text: str) -> int:
        """Drop-in replacement for len() that returns the actual number
        of terminal columns a string occupies on screen.

        DISCLAIMER:
            This function is not perfect. It will overcount when in situation
            of complex multi-emoji combinations tied together with
            Zero-Width Joiners. But the point here is to work well with simpler
            glyphs.
        """
        width: int = 0
        for char in text:
            # Zero-width modifiers (like variation selectors or combining marks)
            if unicodedata.category(char) in ("Mn", "Me", "Cf"):
                continue

            # Wide (W) and Fullwidth (F) characters occupy 2 terminal columns
            if unicodedata.east_asian_width(char) in ("W", "F"):
                width += 2
            else:
                width += 1

        return width

    def __init__(self, **kwargs) -> None:
        theme: Theme | None = kwargs.get("theme", None)
        self.y: int = kwargs.get("y", 0)
        self.x: int = kwargs.get("x", 0)
        self.height: int = kwargs.get("height", 0)
        self.width: int = kwargs.get("width", 0)
        self.title: str = kwargs.get("title", "")
        self.border: bool = kwargs.get("border", False)
        self.cursor_x: int = 0
        self.cursor_y: int = 0

        # DEBUG INFO:
        self.column_num: int = kwargs.get("column_num", 0)
        self.column_len: int = kwargs.get("column_len", 0)

        self.inner_height: int = self.height
        self.inner_width: int = self.width

        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )
        self.logger.debug(
            f"Window {self.column_num}-{self.column_len+1} [{self.title}]; Coord: {self.x},{self.y}, Dimensions: {self.width},{self.height}"
        )

        if self.border:
            self.inner_height = self.height - 2
            self.inner_width = self.width - 2

        if self.inner_height < 1:
            raise ValueError(f"Window '{self.title}' is too shallow.")
        if self.inner_width < 1:
            raise ValueError(f"Window '{self.title}' is too narrow.")
        if self.vislen(self.title) + 2 > self.width:
            raise ValueError(f"The title for window '{self.title}' is too long.")

        self.theme: Theme | None = theme

        self.win: curses.window = curses.newwin(self.height, self.width, self.y, self.x)
        self.borderwin: curses.window | None = None
        if self.border:
            self.borderwin = self.win
            self.win = self.borderwin.derwin(self.inner_height, self.inner_width, 1, 1)
        self.win.scrollok(True)

    def __repr__(self) -> str:
        s: str = (
            f"Window {self.column_num}-{self.column_len+1} [{self.title}]; Coord: {self.x},{self.y}, Dimensions: {self.width},{self.height}"
        )
        if self.border:
            s += "; Bordered"
        return s

    def draw_border(self) -> None:
        if not self.border:
            return

        border_theme: str = "border"
        title_theme: str = "title"
        if self.title.lower() == "warning" or self.title.lower() == "warnings":
            border_theme = "warnborder"
            title_theme = "warntitle"

        if self.borderwin:
            if self.theme:
                self.theme.on(self.borderwin, border_theme)
                self.borderwin.box()
                self.theme.off()
                if self.title and self.vislen(self.title) > 0:
                    self.theme.on(self.borderwin, title_theme)
                    self.borderwin.addstr(0, 2, f"[{self.title}]")
                    self.theme.off()
            else:
                self.borderwin.box()
                if self.title and self.vislen(self.title) > 0:
                    self.borderwin.addstr(0, 2, f"[{self.title}]")

    def printyx(self, y: int, x: int, s: str, **kwargs):
        if x + self.vislen(s) >= self.inner_width:
            raise Exception(
                f"String {s!r} is too long and goes over the window right border: x={x}, len={self.vislen(s)}, inner_width={self.inner_width}."
            )

        theme_key: str = kwargs.get("theme", "general")
        if theme_key == "":
            theme_key = "general"  # Safeguard
        if self.theme:
            self.theme.on(self.win, theme_key)
            self.win.addstr(y, x, s)
            self.theme.off()
        else:
            self.win.addstr(y, x, s)

    def print(self, s: str | TextParagraph | TextList, **kwargs):
        theme_key: str = kwargs.get("theme", "general")
        align: str = kwargs.get("align", "left")
        y: int = kwargs.get("y", self.cursor_y)
        x: int = kwargs.get("x", self.cursor_x)
        newline: bool = kwargs.get("newline", False)
        width: int = kwargs.get("width", self.width - abs(x))
        max_lines: int = kwargs.get("maxlines", 0)

        if align not in ["left", "right", "center"]:
            raise ValueError(
                f"Invalid text alignment '{align}'. Use 'left', 'right' or 'center'."
            )

        if isinstance(s, str) and ("\n" in s or "\r" in s):
            raise ValueError(
                f"String {s!r} contains newline or carriage return characters."
            )

        if isinstance(s, str):
            if align == "right":
                if x < 0:
                    x = self.inner_width - self.vislen(s) - 1 + x
                else:
                    x = self.inner_width - self.vislen(s) - 1
            elif align == "center":
                if x < 0:
                    x = ((self.inner_width - self.vislen(s) - 1) // 2) + x
                else:
                    x = (self.inner_width - self.vislen(s) - 1) // 2

            self.printyx(y, x, s, theme=theme_key)

        elif isinstance(s, TextList) or isinstance(s, TextParagraph):
            last_x: int = 0
            for linenum, line in enumerate(s.get_data(width - 2)):
                if max_lines > 0 and linenum >= max_lines:
                    continue

                if align == "right":
                    if x < 0:
                        x = self.inner_width - self.vislen(line) - 1 + x
                    else:
                        x = self.inner_width - self.vislen(line) - 1
                elif align == "center":
                    if x < 0:
                        x = ((self.inner_width - self.vislen(line) - 1) // 2) + x
                    else:
                        x = (self.inner_width - self.vislen(line) - 1) // 2

                if y >= self.inner_height:
                    y = self.inner_height - 1

                if "\n" in line or "\r" in line:
                    raise ValueError(
                        f"String {s!r} contains newline or carriage return characters."
                    )
                self.printyx(y, x, line, theme=theme_key)
                last_x = x + self.vislen(line)

                y += 1
                if y >= self.inner_height:
                    y = self.inner_height - 1

            self.cursor_x = last_x

        # Calculating the new X
        if isinstance(s, str):
            self.cursor_x += x + self.vislen(s)

        if self.cursor_x >= self.inner_width - 1:
            self.cursor_x = 0
            self.cursor_y += 1

        # Calculating the new Y
        if newline:
            self.cursor_x = 0
            self.cursor_y += 1
        # if self.cursor_y >= self.inner_height:
        #     self.win.scroll(self.cursor_y - self.inner_height)
        #     self.cursor_x = 0
        #     self.cursor_y = self.inner_height - 1
        if self.cursor_y >= self.inner_height:
            if self.borderwin is not None:
                # PDCurses (Windows) can't reliably scroll derwin()s; just clamp instead
                self.cursor_y = self.inner_height - 1
            else:
                if self.cursor_y - self.inner_height > 0:
                    self.win.scroll(self.cursor_y - self.inner_height)
                self.cursor_y = self.inner_height - 1
            self.cursor_x = 0

    def refresh(self) -> None:
        if self.borderwin:
            self.borderwin.noutrefresh()
        self.win.noutrefresh()

    def clear(self) -> None:
        self.win.erase()

    def draw_line(self, **kwargs) -> None:
        if not all(key in kwargs for key in ("x", "y", "direction", "length")):
            raise ValueError("Method draw_line() requires: x, y, direction and length.")
        y: int = kwargs["y"]
        x: int = kwargs["x"]
        direction: int = kwargs["direction"]
        length: int = kwargs["length"]
        theme: str = kwargs.get("theme", "border")

        if direction not in ["horizontal", "vertical", "h", "v"]:
            raise ValueError("Direction must be either horizontal or vertical.")

        if direction in ["horizontal", "h"] and x + length > self.width - x:
            raise ValueError(
                f"Cannot draw a horizontal line of {length} chars because only {self.width - x} chars are to the right of {x} in this window."
            )
        if direction in ["vertical", "v"] and y + length > self.height - y:
            raise ValueError(
                f"Cannot draw a vertical line of {length} chars because only {self.height - y} chars are down of {y} in this window."
            )

        hr: str = "─"
        vr: str = "│"

        s: str = vr
        if direction in ["horizontal", "h"]:
            s = hr * length

        # Draw a horizontal line
        if direction in ["horizontal", "h"]:
            self.print(y=y, x=x, s=s)
        else:
            # Draw a vertical line
            for n in range(length):
                self.print(y=y + n, x=x, s=s, theme=theme)


class LayoutColumn:
    def __init__(self, **kwargs) -> None:
        self.x: int = kwargs["x"]
        self.width: int = kwargs["width"]


class Layout:
    """Defines a layout.

    An entity made of columns, with top and bottom and left and right margins,
    left and right and top and bottom spacings between the columns and
    the windows.

    The columns are defined from left to right. If there is only one column
    it can be set center.
    """

    def __init__(self, **kwargs) -> None:
        self.xspacing: int = kwargs.get("xspacing", 0)
        self.yspacing: int = kwargs.get("yspacing", 0)
        self.xmargin: int = kwargs.get("xmargin", 0)
        self.ymargin: int = kwargs.get("ymargin", 0)
        self.maxy: int = kwargs["maxy"]
        self.maxx: int = kwargs["maxx"]
        self.columns: list[LayoutColumn] = []

        # One of the columns has been center
        self.__center: bool = False

    @property
    def __free_column_space(self) -> int:
        return self.maxx - (
            (self.xmargin * 2)
            + sum([c.width for c in self.columns])
            + (len(self.columns) * self.xspacing)
        )

    @property
    def __next_column_x(self) -> int:
        if len(self.columns) == 0:
            return 0
        return self.columns[-1].x + self.columns[-1].width + self.xspacing

    def add_column(self, width: int) -> None:
        if self.__center:
            raise Exception(
                "Cannot add more columns. One column has been already center."
            )
        if width > self.__free_column_space:
            raise Exception(
                f"Cannot add the {len(self.columns)+1} column to layout: Wider than the remaining free width space of {self.__free_column_space} chars."
            )

        column: LayoutColumn = LayoutColumn(x=self.__next_column_x, width=width)
        self.columns.append(column)

    def set_center(self) -> None:
        if len(self.columns) > 1:
            raise Exception(
                "Method set_center() could be used only if there is just one column. Currently there are more."
            )
        self.columns[0].x = self.maxx // 2 - self.columns[0].width // 2
        self.__center = True


class LayoutManager:
    def __init__(self, **kwargs) -> None:
        self.stdscr: curses.window = kwargs["stdscr"]
        maxy, maxx = self.stdscr.getmaxyx()
        self.layout: Layout = Layout(
            maxx=kwargs.get("maxx", maxx),
            maxy=kwargs.get("maxy", maxy),
            xspacing=kwargs.get("xspacing", 0),
            yspacing=kwargs.get("yspacing", 0),
            xmargin=kwargs.get("xmargin", 0),
            ymargin=kwargs.get("ymargin", 0),
        )
        self.theme: Theme = kwargs.get("theme", ThemePalette().get_theme())
        self.windows: list[list[Window]] = []

    def add_column(self, width: int | None = None) -> None:
        """Adds a new column to the layout.

        If width is None, it will assume the whole remaining width."""

        if width is None:
            width = self.layout.maxx - self.layout.xmargin * 2
            if len(self.layout.columns) > 0:
                width = self.layout.maxx - (
                    self.layout.columns[-1].x
                    + self.layout.columns[-1].width
                    + self.layout.xspacing
                    + self.layout.xmargin
                )

        self.layout.add_column(width)

    def set_two_columns(self) -> None:
        r"""Splits the screen in two columns with the same width"""

        maxx: int = self.layout.maxx - self.layout.xmargin * 2  # - self.layout.xspacing
        width: int = maxx // 2

        self.add_column(width)
        self.add_column(width)

    def set_three_columns(self) -> None:
        r"""Splits the screen in three columns with the same width"""

        maxx: int = self.layout.maxx - self.layout.xmargin * 2  # - self.layout.xspacing
        width: int = maxx // 3
        width_last: int = width

        # Just in case check:
        # The last column would be decreased if the total width exceeds maxx.
        # Seriously - no idea if such thing would ever happen, but as I said:
        # I am doing this right here just in case.
        width_last -= maxx - (width * 2 + width_last)

        self.add_column(width)
        self.add_column(width)
        self.add_column(width_last)

    def set_four_columns(self) -> None:
        r"""Splits the screen in four columns with the same width"""

        maxx: int = self.layout.maxx - self.layout.xmargin * 2  # - self.layout.xspacing
        width: int = maxx // 4
        width_last: int = width

        # Just in case check:
        # The last column would be decreased if the total width exceeds maxx.
        # Seriously - no idea if such thing would ever happen, but as I said:
        # I am doing this right here just in case.
        width_last -= maxx - (width * 3 + width_last)

        self.add_column(width)
        self.add_column(width)
        self.add_column(width)
        self.add_column(width_last)

    def add_window(self, **kwargs) -> Window:
        """Creates a new window, adds it to the internal list and returns it as well.

        Parameter Overlap controls if the window would overlap another column.
        It contains the column number to which end it will overflow.

        Default column_num is 0.
        If x or width are not set, defaults are the set column x and width.
        If overlap is set, width will be set to snap accordingly.
        """

        column_num: int = kwargs.get("column_num", 0)

        if len(self.layout.columns) == 0:
            raise Exception("No layout columns defined. New window cannot be added.")
        if not (0 <= column_num < len(self.layout.columns)):
            raise Exception(
                f"New window cannot be added as column '{column_num}' does not exist."
            )

        title: str = kwargs.get("title", "")
        height: int = kwargs.get("height", 3)
        width: int = kwargs.get("width", self.layout.columns[column_num].width)
        overlap: int = kwargs.get("overlap", column_num)
        y: int = kwargs.get("y", 0)
        x: int = kwargs.get("x", self.layout.columns[column_num].x)
        border: bool = kwargs.get("border", False)

        if len(self.windows) < len(self.layout.columns):
            self.windows.append([])

        window_index: int = 0
        if len(self.windows[column_num]) > 0:
            window_index = len(self.windows[column_num]) - 1

        if overlap < column_num:
            raise Exception(
                f"New Window[{column_num}][{window_index}]['{title}'] have an overlap set to a previous column."
            )
        if overlap > len(self.layout.columns) - 1:
            raise Exception(
                f"New Window[{column_num}][{window_index}]['{title}'] have an overlap set to a non-existing column."
            )
        if overlap != column_num:
            columns_width: int = 0
            for col in range(column_num, overlap + 1):
                columns_width += self.layout.columns[col].width
            width = columns_width + (overlap - column_num) * self.layout.xspacing

        if x + width > self.layout.maxx:

            raise Exception(
                f"New Window[{column_num}][{window_index}]['{title}'] is wider than the screen."
            )

        # y: int = 0
        if y == 0:
            # Detect overlap of windows of previous columns
            col_index = 0
            for col_index in range(column_num + 1):
                for window in self.windows[col_index]:
                    if window.x + window.width > x:
                        y = window.y + window.height + self.layout.ymargin

        if y + height > self.layout.maxy:
            raise Exception(
                f"New Window[{column_num}][{window_index}]['{title}'] height is going below the screen. Has the terminal been resized?"
            )

        window: Window = Window(
            theme=self.theme,
            y=y,
            x=x,
            height=height,
            width=width,
            title=title,
            border=border,
            column_num=column_num,
            column_len=len(self.windows[column_num]),
        )
        self.windows[column_num].append(window)

        return window

    def draw_windows(self) -> None:
        for column in self.windows:
            for window in column:
                window.draw_border()

    def refresh_screen(self) -> None:
        """Pushes all the changes to the screen"""

        self.stdscr.noutrefresh()

        for column in self.windows:
            for window in column:
                if window.borderwin:
                    window.borderwin.touchwin()
                window.win.touchwin()
                window.refresh()


class WarningsManager:
    """Manages the warnings. This logger is responsible to read the disasters
    file, filter out the old events and show the new ones only.

    Log format (CSV):
    date, time, homeremote, location, label, message
    """

    RECLEN: int = 6  # The number of fields in the CSV

    def __init__(self) -> None:
        self.keep_max_days: int = 1
        self._messages: list[list[str]] = []
        self.filename: Path = Path.home() / ".tuiweathergirl_warnings_log"
        self.home_location: str = ""

        # No dot in the filename
        if os.name == "nt":
            self.filename: Path = Path.home() / "tuiweathergirl_warnings.log"

        self.filename = self.filename.resolve()

    def delete(self) -> None:
        if self.filename.exists():
            self.filename.unlink()

    def _is_old(self, message_date: str) -> bool:
        today_midnight = datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        cutoff_date = today_midnight - timedelta(days=self.keep_max_days)
        mdate = datetime.strptime(message_date, "%Y-%m-%d")
        return mdate < cutoff_date

    def _cleanup(self) -> None:
        # sublist[0] contains the message date
        self._messages = [
            sublist for sublist in self._messages if not self._is_old(sublist[0])
        ]

        # If the only message is a "No warnings" message - delete it
        # (we will insert new one with updated time)
        if (
            len(self._messages) == 1
            and self._messages[0][2] in ["", "***", "meh"]
            and "All quiet" in self._messages[0][-1]
        ):
            self._messages = []

    def _load(self) -> None:
        if not self.filename.exists():
            return

        with open(self.filename, "r", newline="", encoding="utf-8") as f:
            reader = csv.reader(f)
            self._messages = [row for row in list(reader) if len(row) == self.RECLEN]

    def _save(self) -> None:
        with open(self.filename, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerows(self._messages)

    def get_warnings(self, number_of_messages: int = 0) -> list[list[str]]:
        self._load()
        self._cleanup()
        self._save()

        # This will append a status message saying there are no
        # warnings at the moment
        if len(self._messages) == 0 and len(self.home_location) > 0:
            self.append("***")

        if number_of_messages == 0:  # Return all warnings
            return self._messages

        return self._messages[-number_of_messages:]

    def append(
        self,
        homeremote: str,
        location: str = "",
        label: str = "general",
        message: str = " No warnings at the moment. All quiet.",
        ogdate: str | None = None,
        ogtime: str | None = None,
    ) -> None:
        """Append and save all messages"""

        if homeremote.lower() not in [
            "home",
            "remote",
            "emergency",
            "disaster",
            "",
            "***",
            "meh",
            "error",
        ]:
            raise ValueError(f"Invalid value '{homeremote}' for homeremote.")

        if len(self._messages) == 0:
            self._load()

        if len(location) == 0:
            location = self.home_location

        now: datetime = datetime.now()
        date_str = now.strftime("%Y-%m-%d")
        time_str = now.strftime("%H:%M:%S")

        message_entry: list[str] = [
            date_str,
            time_str,
            homeremote,
            location,
            label.upper(),
            message,
        ]

        # Preserving original date and time, if needed
        if ogdate and ogtime:
            message_entry[0] = ogdate
            message_entry[1] = ogtime

        # Duplicate check: same message, regardless of date
        # (an event like an earthquake or wildfire can otherwise get
        # re-logged once the date rolls over, e.g. new day, new time)
        for m in self._messages:
            if message.lower() == m[-1].lower():
                return

        self._cleanup()
        self._messages.append(message_entry)
        self._save()

    def apply_format(self, message_list) -> str:
        dates, times, homeremote, location, label, message = message_list
        message_str: str = f"[{dates}][{times}][{location:.15}][{label}]{message}"
        return message_str[:MAXSTRINGLEN]


class Astronomer:
    """Celestial data"""

    def utc2local(self, utc_time_str: str, timezone_name: str) -> str:
        """Time converter"""
        hours, minutes = map(int, utc_time_str.split(":"))

        utc_dt: datetime = datetime.now(timezone.utc).replace(
            hour=hours, minute=minutes, second=0, microsecond=0
        )

        local_tz = ZoneInfo(timezone_name)
        local_dt = utc_dt.astimezone(local_tz)

        return local_dt.strftime("%H:%M")

    def get_sun_times(
        self, lat: float, lon: float, timezone_name: str, reqtimeout: int
    ) -> dict[str, str]:
        """Fetches sunrise and sunset for today in UTC."""

        logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )
        logger.info("** Querying Sunrise-Sunset **")

        url = f"https://api.sunrise-sunset.org/json?lat={lat}&lng={lon}&formatted=0"
        logger.debug(f"URL={url}")

        try:
            data = requests.get(url, timeout=reqtimeout).json()
        except Exception:
            # Just making it more user-friendly
            raise Exception(
                f"Cannot get sun times. Try again in a minute. Request timeout ({reqtimeout})."
            )

        logger.debug(f"JSONDATA={pf(data)}")

        if data["status"] == "OK":
            # Format: 2026-05-04T04:12:34+00:00
            sunrise: str = data["results"]["sunrise"][11:16]
            sunset: str = data["results"]["sunset"][11:16]
            sunrise = self.utc2local(sunrise, timezone_name)
            sunset = self.utc2local(sunset, timezone_name)
            return {"sunrise": sunrise, "sunset": sunset}

        return {}

    def get_zodiac_sign(self) -> str:
        """Returns the current Western astrological sign based on today's date."""

        now: datetime = datetime.now()
        m, d = now.month, now.day

        if (m == 12 and d >= 22) or (m == 1 and d <= 19):
            return "Capricorn"
        if (m == 1 and d >= 20) or (m == 2 and d <= 18):
            return "Aquarius"
        if (m == 2 and d >= 19) or (m == 3 and d <= 20):
            return "Pisces"
        if (m == 3 and d >= 21) or (m == 4 and d <= 19):
            return "Aries"
        if (m == 4 and d >= 20) or (m == 5 and d <= 20):
            return "Taurus"
        if (m == 5 and d >= 21) or (m == 6 and d <= 20):
            return "Gemini"
        if (m == 6 and d >= 21) or (m == 7 and d <= 22):
            return "Cancer"
        if (m == 7 and d >= 23) or (m == 8 and d <= 22):
            return "Leo"
        if (m == 8 and d >= 23) or (m == 9 and d <= 22):
            return "Virgo"
        if (m == 9 and d >= 23) or (m == 10 and d <= 22):
            return "Libra"
        if (m == 10 and d >= 23) or (m == 11 and d <= 21):
            return "Scorpio"
        if (m == 11 and d >= 22) or (m == 12 and d <= 21):
            return "Sagittarius"

        return ""

    def get_chinese_zodiac(self) -> str:
        """Returns the current Chinese Animal Year."""

        animals = [
            "Rat",
            "Ox",
            "Tiger",
            "Rabbit",
            "Dragon",
            "Snake",
            "Horse",
            "Goat",
            "Monkey",
            "Rooster",
            "Dog",
            "Pig",
        ]
        now: datetime = datetime.now()
        m, d, y = now.month, now.day, now.year

        animal: int = (y - 1900) % 12

        # Previous animal
        # Of course Chinese New Year fluctuates, but I have no intention to
        # calculate it properly. Roughly is enough for this app.
        if m == 1 and d < 20:
            animal -= 1

        return animals[animal]

    def get_moon_phase(self) -> str:
        """Returns a simple string description of the current moon phase."""

        # Simplified calculation based on the known New Moon of Jan 6, 2000
        now: datetime = datetime.now()
        diff = now - datetime(2000, 1, 6, 18, 14)
        days = diff.total_seconds() / 86400
        lunation = days % 29.530588853

        if lunation < 1.84:
            return "New Moon"
        if lunation < 5.53:
            return "Waxing Crescent"
        if lunation < 9.22:
            return "First Quarter"
        if lunation < 12.91:
            return "Waxing Gibbous"
        if lunation < 16.60:
            return "Full Moon"
        if lunation < 20.29:
            return "Waning Gibbous"
        if lunation < 23.98:
            return "Last Quarter"
        if lunation < 27.67:
            return "Waning Crescent"

        return "New Moon"


class Bulgarian:
    def get_history_fact(self, reqtimeout: int, keyword: str = "") -> str:
        """Checks Wikipedia for major Bulgarian historical events occurring
        today or yesterday.
        """

        logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

        now: datetime = datetime.now()
        yesterday: datetime = now - timedelta(days=1)

        # Specific keywords to filter for Bulgarian national history
        keywords: list[str] = [
            "Bulgaria",
            "Levski",
            "Botev",
            "Shipka",
            "Plovdiv",
            "Sofia",
            "Turnovo",
            "Tarnovo",
            "Bulgarian Empire",
            "Cyrillic",
        ]

        if len(keyword) > 0:
            keywords.append(keyword)  # Just in case

        # We check today first, then yesterday
        for date_obj, label in [(now, "today"), (yesterday, "yesterday")]:
            logger.info("** Querying Wikipedia **")

            # Wikipedia On This Day API expects month/day WITHOUT leading zeros (e.g. '3' instead of '03')
            month: str = str(int(date_obj.strftime("%m")))
            day: str = str(int(date_obj.strftime("%d")))

            # url: str = "https://en.wikipedia.org/api/rest_v1/feed/onthisday/events/3/3"
            url: str = (
                f"https://en.wikipedia.org/api/rest_v1/feed/onthisday/events/{month}/{day}"
            )
            logger.debug(f"URL={url}")

            try:
                response: requests.Response = requests.get(
                    url, headers=HTTPHEADERS, timeout=reqtimeout
                )
            except Exception:
                logger.error(
                    "Cannot get history fact (Wikipedia). Will try again on the next refresh."
                )
                return ""

            logger.debug(f"RESPONSE={pf(response)}")

            if response.status_code != 200:
                continue

            data = response.json()
            # We look through selected major events, general events, and deaths (for Tsars/Heroes)
            collections = (
                data.get("selected", [])
                + data.get("events", [])
                + data.get("deaths", [])
            )

            for event in collections:
                text: str = event.get("text", "")
                year: str = event.get("year", "Unknown")

                # Bulgaria was found in 681AD
                # and was a communist country from 1944 until 1989
                # so we skip this period
                if int(year) < 681 or 1944 < int(year) < 1989:
                    continue

                # Check if the event matches our Bulgarian history keywords
                if any(kw.lower() in text.lower() for kw in keywords):
                    # Clean the text slightly (removing Wikipedia's parentheticals)
                    clean_text = text.split(" (")[0].strip()

                    return f"{year}: {clean_text}"

        return ""


class SpaceWeatherAdvisor:
    """NOAA advisories"""

    def __init__(self, reqtimeout: int) -> None:
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )
        self.reqtimeout: int = reqtimeout

    def get_geomagnetic_scales(self) -> dict[str, any]:
        """Fetches the active categorical NOAA scales (G, S, R)"""

        # NOTE: R-radio blackouts
        # Caused by: solar flares
        # Bursts of electromagnetic radiation: x-ray and extreme ultra-violet
        # Result: Commercial/any radiotransmissions are absorbed in the
        # upper athmosphere, thus not reaching receivers - blackout.
        # AFFECTED: Aviation, HAM, GPS precision

        # NOTE: S-solar radiation storms
        # Caused by: solar eruptions
        # AFFECTED: Satellite comms

        # NOTE: G-geomagnetic storms,
        # Caused by: coronal mass ejections, high-speed solar winds
        # AFFECTED: Power grids, satellites, migratory life, bio-sensitives

        self.logger.info("** Querying SWPC NOAA (scales) **")

        url: str = "https://services.swpc.noaa.gov/products/noaa-scales.json"
        # fallback: dict = {"G": 0, "S": 0, "R": 0, "DateStamp": "", "TimeStamp": ""}
        fallback: dict = {"G": [0, 0], "S": [0, 0], "R": [0, 0]}
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(
                url,
                timeout=self.reqtimeout,
            )
            self.logger.debug(f"RESPONSE={pf(response)}")

            if response.status_code == 200:
                data = response.json()

                # '0' represents the latest observed metrics block
                latest_obs: dict = data.get("0", {})
                if not latest_obs:
                    return fallback

                predicted: dict = data.get("1", {})

                # Extract and parse categorical scales (G, S, R)
                # Converting values from strings (e.g., "0") to clean integers
                g_scale = int(latest_obs.get("G", {}).get("Scale") or 0)
                s_scale = int(latest_obs.get("S", {}).get("Scale") or 0)
                r_scale = int(latest_obs.get("R", {}).get("Scale") or 0)

                g_predicted = int(predicted.get("G", {}).get("Scale") or 0)
                s_predicted = int(predicted.get("S", {}).get("Prob") or 0)
                r_predicted = int(predicted.get("R", {}).get("MinorProb") or 0)

                return {
                    "G": [g_scale, g_predicted],
                    "S": [s_scale, s_predicted],
                    "R": [r_scale, r_predicted],
                    # "DateStamp": latest_obs.get("DateStamp", ""),
                    # "TimeStamp": latest_obs.get("TimeStamp", ""),
                }
        except Exception:
            self.logger.error(
                "Cannot geomagnetic scales (SWPC NOAA). Will try again on the next refresh."
            )

        return fallback

    def get_kp_index(self) -> float:
        """Fetches the current planetary Kp-index"""

        self.logger.info("** Querying SWPC NOAA (Kp-index) **")

        # NOTE: The Kp-index goes before the G-scale of geomagnetic anomalies
        # at least to my understanding

        url: str = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
        self.logger.debug(f"URL={url}")
        try:
            response: requests.Response = requests.get(
                url,
                timeout=self.reqtimeout,
            )
            self.logger.debug(f"RESPONSE={pf(response)}")

            if response.status_code == 200:
                data = response.json()
                if isinstance(data, list) and len(data) > 0:
                    # Entries are chronologically ordered; the last item is the most recent
                    latest_entry = data[-1]

                    # Modern SWPC format represents Kp as a raw floating-point number
                    kp_val = latest_entry.get("Kp")
                    if kp_val is not None:
                        return float(kp_val)
        except Exception:
            self.logger.error(
                "Cannot get planetary Kp-index. Will try again on the next refresh."
            )

        return 0.0

    def _get_geomagnetic_warning(
        self, kp_index: float, g_scale: list[int] | None = None
    ) -> str:
        """Evaluates current space weather data to produce biomechanical alerts.
        Warns individuals with high electrosensitivity or barometric/magnetic sensitivity.
        """
        # If g_scale is not supplied, we mathematically derive it from Kp.
        # Kp 5 = G1, Kp 6 = G2, Kp 7 = G3, Kp 8 = G4, Kp 9 = G5
        g_scale_calculated: int = int(kp_index - 4) if kp_index >= 5 else 0

        if g_scale is None:
            g_scale = [0, 0]

        observed, predicted = g_scale

        if observed != g_scale_calculated:
            observed = g_scale_calculated

        # Kp index 4 represents 'Active' geomagnetic activity (borderline storm)
        if 4.0 <= kp_index < 5.0:
            return f"[Kp {kp_index:.2f}] Unsettled atm.charging. Ev.migraines, restlessness, mild joint irritation (Predicted:G{predicted})."
        elif kp_index >= 5.0:
            # Kp index 5+ marks the formal threshold for active Geomagnetic Storms
            storm_descriptions = {
                0: "G0 No storms",
                1: "G1 Minor",
                2: "G2 Moderate",
                3: "G3 Strong",
                4: "G4 Severe",
                5: "G5 Extreme",
            }
            storm_label = storm_descriptions.get(observed, f"Storm (G{observed})")

            return f"[Kp {kp_index:.2f}] {storm_label} storm. Risk: Blood pressure, headaches, insomnia, sev.joint pain (Predicted:G{predicted})."

        return ""

    def _get_solar_radiation_warning(self, s_scale: list[int]) -> str:
        """Generates warning strings for the S-scale (0 to 5)."""

        observed, predicted = s_scale

        if observed < 2:
            return ""

        warnings: dict[int, str] = {
            2: "[MODERATE RAD.STORM] Minor exposure to passengers and flight crews on high-altitude polar routes.",
            3: "[STRONG RAD.STORM] Increased exposure to passengers and crews on high-altitude on polar routes.",
            4: "[SEVERE RAD.STORM] Flights to avoid polar regions! Elevated rad.exposure for crews and passengers. Blackout for polar HF comms.",
            5: "[EXTREME RAD.STORM][CRITICAL] Shut down for polar and high alt.routes! High exposure for crews and passengers! Blackouts!",
        }

        warning: str = warnings.get(
            observed,
            f"[WARNING] Elevated Solar Radiation (S{observed}/5). Storm probability: {predicted}%.",
        )
        return warning

    def _get_radio_blackout_warning(self, r_scale: list[int]) -> str:
        """Generates warning strings for the R-scale (0 to 5)."""

        observed, predicted = r_scale

        if observed < 2:
            return ""

        warnings: dict[int, str] = {
            2: "[MODERATE RADIO BLACKOUT] HF comms limited. Marine and aviation operators may lose contact for 10 mins. Minor GPS errs.",
            3: "[STRONG RADIO BLACKOUT] Loss of contact for 1 hour for marine, aviation and amateur ops. GPS errors.",
            4: "[SEVERE RADIO BLACKOUT] HF radio blackout for entire sunlit side of Earth. No comms up to 2hrs. No GPS.",
            5: "[EXTREME RADIO BLACKOUT] COMPLETE HF RADIO BLACKOUT. No comms for few hrs. No GPS.",
        }

        warning = warnings.get(
            observed,
            f"[WARNING] Active Radio Blackout (R{observed}/5)(Prediction: {predicted}% for Moderate).",
        )
        return warning

    def get_warnings(
        self, kp_index: float, geomagnetic_scales: dict[str, any]
    ) -> list[list[str]]:
        geomagnetic_warning: str = self._get_geomagnetic_warning(
            kp_index, geomagnetic_scales["G"]
        )
        solar_radiation_warning: str = self._get_solar_radiation_warning(
            geomagnetic_scales["S"]
        )
        radio_blackout_warning: str = self._get_radio_blackout_warning(
            geomagnetic_scales["R"]
        )

        warnings: list[list[str]] = []

        if geomagnetic_warning:
            geomagnetic_entry: list[str] = [
                "remote",
                "SPACE",
                "GEOMAGNETIC",
                geomagnetic_warning,
            ]
            if any(
                x in geomagnetic_warning for x in ("[STRONG", "[SEVERE", "[EXTREME")
            ):
                geomagnetic_entry = [
                    "EMERGENCY",
                    "SPACE",
                    "GEOMAGNETIC",
                    geomagnetic_warning,
                ]
            warnings.append(geomagnetic_entry)

        if solar_radiation_warning:
            solar_radiation_entry: list[str] = [
                "remote",
                "SPACE",
                "SOLARRADIATION",
                solar_radiation_warning,
            ]
            if any(
                x in solar_radiation_warning for x in ("[STRONG", "[SEVERE", "[EXTREME")
            ):
                solar_radiation_entry = [
                    "EMERGENCY",
                    "SPACE",
                    "SOLARRADIATION",
                    solar_radiation_warning,
                ]
            warnings.append(solar_radiation_entry)

        if radio_blackout_warning:
            radioblackout_entry: list[str] = [
                "remote",
                "SPACE",
                "RADIOBLACKOUT",
                radio_blackout_warning,
            ]
            if any(
                x in radio_blackout_warning for x in ("[STRONG", "[SEVERE", "[EXTREME")
            ):
                radioblackout_entry = [
                    "EMERGENCY",
                    "SPACE",
                    "RADIOBLACKOUT",
                    radio_blackout_warning,
                ]
            warnings.append(radioblackout_entry)

        return warnings


class DisasterAdvisor:
    """Monitors the national disasters"""

    def __init__(self, **kwargs) -> None:
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

        # Controlling which types of disasters we want monitored
        # Default - we monitor everything
        self.earthquake: bool = True
        self.flood: bool = True
        self.wildfire: bool = True
        self.drought: bool = True
        self.storm: bool = True
        self.tsunami: bool = True
        self.volcano: bool = True

        params: dict[str, str] = {
            "quake": "earthquake",
            "earthquake": "earthquake",
            "flood": "flood",
            "fire": "wildfire",
            "wildfire": "wildfire",
            "drought": "drought",
            "storm": "storm",
            "cyclone": "storm",
            "tsunami": "tsunami",
            "volcanoe": "volcanoe",
        }

        for param in params.keys():
            if param in kwargs:
                setattr(self, params[param], kwargs[param])

        self.reqtimeout: int = kwargs["reqtimeout"]

    def _haversine(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
        """Calculates distance in km between two points. Implementation of the haversine formula for the planet Earth."""

        R: int = 6371  # Earth mean radius
        dlat: float = math.radians(lat2 - lat1)
        dlon: float = math.radians(lon2 - lon1)
        a: float = (
            math.sin(dlat / 2) ** 2
            + math.cos(math.radians(lat1))
            * math.cos(math.radians(lat2))
            * math.sin(dlon / 2) ** 2
        )

        return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))

    def _get_affected_provinces(self, url: str) -> list[str]:
        """I assume that more than one province might be affected"""

        provinces: list[str] = []

        try:
            response: requests.Response = requests.get(
                url,
                timeout=self.reqtimeout,
            )
        except Exception:
            # Just making it more user-friendly
            self.logger.error(
                "Cannot get disaster affected provinces. Will try again on the next refresh."
            )
            return []

        if response.status_code != 200:
            return []

        data = response.json()

        if "datums" not in data:
            return []

        for datum in data["datums"]:
            for subdatum in datum["datum"]:
                for scalar in subdatum["scalars"]["scalar"]:
                    if scalar["name"].upper() == "ADMIN_NAME":
                        provinces.append(scalar["value"])

        return provinces

    def get_disasters(self, countries_list: list[str]) -> list[list[str]]:
        self.logger.info("** Querying GDACS **")

        event_types: dict[str, str] = {
            "DR": "DROUGHT",
            "EQ": "EARTHQUAKE",
            "FL": "FLOOD",
            "TC": "STORM",
            "TS": "TSUNAMI",
            "VO": "VOLCANO",
            "WF": "WILDFIRE",
        }

        disasters: list[list[str]] = []
        url = "https://www.gdacs.org/gdacsapi/api/events/geteventlist/latest"
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(
                url,
                timeout=self.reqtimeout,
            )
        except Exception:
            disasters.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    " Cannot get disasters (GDACS). Will try again on the next refresh.",
                ]
            )
            return disasters

        self.logger.debug(f"RESPONSE={pf(response)}")

        if response.status_code != 200:
            return []

        data = response.json()

        for feature in data["features"]:
            now: datetime = datetime.now()
            todate: datetime = datetime.fromisoformat(feature["properties"]["todate"])
            old: timedelta = now - todate

            if (
                old.days > 2
                or feature["properties"]["istemporary"].lower() == "true"
                or feature["properties"]["iscurrent"].lower() == "false"
                or (
                    feature["properties"]["alertlevel"].lower() == "green"
                    and feature["properties"]["episodealertlevel"].lower() == "green"
                )
            ):
                continue

            matching_locations: list[str] = []
            for location in feature["properties"]["affectedcountries"]:
                if location["iso2"] in countries_list:
                    matching_locations.append(location["countryname"])

            if len(matching_locations) == 0:
                continue

            # At this point we have a location of interest
            # but do we monitor this type of disasters?
            label: str = event_types[feature["properties"]["eventtype"]]
            if not hasattr(self, label):
                continue

            source: str = feature["properties"]["source"]
            severity: str = feature["properties"]["severitydata"]["severitytext"]

            # Getting the details
            provinces: list[str] = []
            details_url: str = feature["properties"]["url"]["details"]

            try:
                details_response: requests.Response = requests.get(
                    details_url,
                    timeout=self.reqtimeout,
                )
            except Exception:
                disasters.append(
                    [
                        "ERROR",
                        "ALL",
                        "ERROR",
                        " Cannot get disaster details (GDACS). Will try again on the next refresh.",
                    ]
                )
                return disasters

            if details_response.status_code == 200:
                details_data = details_response.json()

                if (
                    "properties" in details_data
                    and "impacts" in details_data["properties"]
                ):
                    for impact in details_data["properties"]["impacts"]:
                        if "resource" in impact and "impact" in impact["resource"]:
                            provinces = self._get_affected_provinces(
                                impact["resource"]["impact"]
                            )

            for location in matching_locations:
                if not provinces:
                    message: str = f"{severity} ({source})"
                    disasters.append(["DISASTER", location, label, message])
                else:
                    for province in provinces:
                        message = f"[{province}] {severity} ({source})"
                        disasters.append(["DISASTER", location, label, message])

        return disasters

    def get_detailed_fires(self, lat: str, lon: str, timezone: str) -> list[list[str]]:
        """This time pulling from NASA FIRMS if the API key is available"""

        expected_columns: int = 14

        self.logger.info("** Querying NASA FIRMS **")

        apikey: str = os.getenv(NASAFIRMS_APIKEY_ENV_VARNAME)
        if not apikey:
            return []

        latt: float = float(lat)
        lonn: float = float(lon)
        fire_list: list[list[str]] = []

        url = f"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{apikey}/VIIRS_NOAA21_NRT/{lonn-1},{latt-1},{lonn+1},{latt+1}/1"
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response | list[str] = requests.get(
                url,
                timeout=self.reqtimeout,
            )
        except Exception:
            fire_list.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    " Cannot get wildfire data (FIRMS). Will try again on the next refresh.",
                ]
            )
            return fire_list

        self.logger.debug(f"RESPONSE={pf(response)}")

        if response.status_code != 200:
            self.logger.error(
                f"NASA FIRMS ERROR: HTTP {response.status_code}: {response.text}"
            )
            fire_list.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    f" Cannot get wildfire data (FIRMS): HTTP Err {response.status_code}: {response.text}",
                ]
            )
            return fire_list

        text = response.text.strip()
        response = response.text.split("\n")

        if text.startswith("<!DOCTYPE") or text.startswith("<html"):
            self.logger.error(f"NASA FIRMS ERROR: Not a CSV: {response.text}")
            fire_list.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    f" Cannot get wildfire data (FIRMS): Not a CSV: {response.text}",
                ]
            )
            return fire_list

        if (
            "unauthorized" in text.lower()
            or "rate limit" in text.lower()
            or "bad request" in text.lower()
        ):
            self.logger.error(f"NASA FIRMS ERROR: {text}")
            fire_list.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    f" Cannot get wildfire data (FIRMS): {text}",
                ]
            )
            return fire_list

        headers: bool = True
        for line in response:  # Skipping the CSV header
            fields: list[str] = line.split(",")

            if headers:
                if not line:
                    fire_list.append(
                        [
                            "ERROR",
                            "ALL",
                            "ERROR",
                            " Cannot get wildfire data (FIRMS). Will try again on the next refresh.",
                        ]
                    )
                    return fire_list

                if len(fields) != expected_columns:
                    fire_list.append(
                        [
                            "ERROR",
                            "ALL",
                            "ERROR",
                            f" Cannot get wildfire data (FIRMS): {line}",
                        ]
                    )
                    return fire_list

                # Ok, we got the headers, let's move on
                headers = False
                continue

            # No fires detected in the area
            if not line:
                continue

            if len(fields) != expected_columns:
                self.logger.error(f"Unexpected FIRMS response line: {line!r}")
                fire_list.append(
                    [
                        "ERROR",
                        "ALL",
                        "ERROR",
                        f" Cannot get wildfire data (FIRMS): {line}",
                    ]
                )
                return fire_list

            (
                f_lat,
                f_lon,
                ti4,
                scan,
                track,
                f_date,
                f_time,
                satellite,
                instrument,
                confidence,
                ver_,
                ti5,
                frp,
                daynight,
            ) = fields
            f_lat = float(f_lat)
            f_lon = float(f_lon)
            ti4 = float(ti4)
            scan = float(scan)
            track = float(track)
            ti5 = float(ti5)
            frp = float(frp)

            distance = self._haversine(latt, lonn, f_lat, f_lon)

            # Scanning pixel area
            pixel_area_km2: float = scan * track
            f_size: str = "LARGE"
            if pixel_area_km2 <= 0.20:
                f_size = "small area"
            elif pixel_area_km2 <= 0.45:
                f_size = "MEDIUM AREA"

            # Heat Signature via FRP (Fire Radiative Power)
            heat: str = "STRONG"
            if frp < 5.0:
                heat = "Weak"
            elif frp <= 20.0:
                heat = "MEDIUM"

            confident: str = ""
            if confidence == "L":
                confident = " (Possible False Alarm/Low Certainty)"

            time_str: str = f"{f_date} {f_time.zfill(4)}"
            utc_dt = datetime.strptime(time_str, "%Y-%m-%d %H%M").replace(
                tzinfo=ZoneInfo("UTC")
            )
            local_dt = utc_dt.astimezone(ZoneInfo(timezone))
            datetime_str: str = local_dt.strftime("%Y-%m-%d %H:%M%Z")

            if distance <= 100:  # km
                location: str = f"{f_lat},{f_lon}"
                message: str = (
                    f"[{datetime_str}][{distance:.1f}km] {heat} fire on a {f_size} {confident}"
                )
                fire_list.append(["DISASTER", location, "WILDFIRE", message])

        return fire_list

    @staticmethod
    def _felt_category(mmi: float) -> str:
        """Translates a Modified Mercalli Intensity (MMI) value into a
        plain-English description of how strongly a quake is typically
        felt on the surface.
        """

        if mmi < 2:
            return "NOT.FELT"
        elif mmi < 4:
            return "WEAK.FELT"
        elif mmi < 6:
            return "MOD.FELT"
        elif mmi < 8:
            return "STRONG.FELT"
        else:
            return "MONSTER QUAKE"

    def _estimate_mmi(self, magnitude: float, distance_km: float) -> float:
        """Rough fallback estimate of the Modified Mercalli Intensity (MMI),
        used only when a data source doesn't hand us an official measured
        value (EMSC never does; USGS only does for events with a ShakeMap).

        This is a coarse magnitude/distance heuristic, NOT a real ShakeMap.
        It exists purely so every quake gets a sensible felt-strength label
        instead of being silently dropped or mislabeled "not felt" just
        because the official mmi field was missing.
        """

        distance_km = max(distance_km, 1.0)
        mmi: float = 1.5 * magnitude - 2.5 * math.log10(distance_km) + 2.5
        return max(1.0, min(mmi, 10.0))

    def _get_usgs_quakes(
        self,
        lat: str,
        lon: str,
        start_time: str,
        max_distance_km: float,
        min_magnitude: float,
    ) -> tuple[list[dict], list[list[str]]]:
        """Pulls earthquakes from the USGS FDSN event web service.

        Returns (raw_quakes, errors) - raw, not yet formatted, since the
        caller still needs to de-duplicate against other sources before
        turning these into display strings.
        """

        self.logger.info("** Querying USGS **")

        raw_quakes: list[dict] = []
        errors: list[list[str]] = []

        url: str = (
            "https://earthquake.usgs.gov/fdsnws/event/1/query"
            f"?format=geojson&starttime={start_time}&latitude={lat}&longitude={lon}"
            f"&maxradiuskm={max_distance_km}&minmagnitude={min_magnitude}"
        )
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(url, timeout=self.reqtimeout)
        except Exception:
            errors.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    " Cannot get earthquakes (USGS). Will try again on the next refresh.",
                ]
            )
            return raw_quakes, errors

        self.logger.debug(f"RESPONSE={pf(response)}")
        if response.status_code != 200:
            return raw_quakes, errors

        data = response.json()

        for feature in data.get("features", []):
            properties: dict = feature["properties"]
            mmi_raw = properties.get("mmi")

            quake_time: datetime = datetime.fromtimestamp(
                properties["time"] / 1000, tz=timezone.utc
            )

            raw_quakes.append(
                {
                    "source": "USGS",
                    "time": quake_time,
                    "datetime": quake_time.strftime("%Y-%m-%d %H:%M") + "UCT",
                    "magnitude": float(properties["mag"]),
                    "lat": float(feature["geometry"]["coordinates"][1]),
                    "lon": float(feature["geometry"]["coordinates"][0]),
                    "depth": float(feature["geometry"]["coordinates"][-1]),
                    "place": properties["place"],
                    "mmi": float(mmi_raw) if mmi_raw is not None else None,
                    "tsunami": bool(properties.get("tsunami") or 0),
                    "alert": properties.get("alert") or "",
                }
            )

        return raw_quakes, errors

    def _get_emsc_quakes(
        self,
        lat: str,
        lon: str,
        start_time: str,
        max_distance_km: float,
        min_magnitude: float,
    ) -> tuple[list[dict], list[list[str]]]:
        """Pulls earthquakes from the EMSC (European-Mediterranean Seismological
        Centre) FDSN event web service. EMSC picks up many small regional
        quakes - e.g. in the Balkans - that never make it into USGS's global
        catalogue, since it aggregates feeds from national networks.

        Returns (raw_quakes, errors) - see _get_usgs_quakes().
        """

        self.logger.info("** Querying EMSC **")

        raw_quakes: list[dict] = []
        errors: list[list[str]] = []

        # EMSC's circle-search radius is in degrees, not km (unlike USGS).
        # ~111.195 km per degree, assuming a spherical Earth - consistent
        # with the mean radius used in _haversine().
        km_per_degree: float = 111.195
        max_radius_degrees: float = max_distance_km / km_per_degree

        url: str = (
            "https://www.seismicportal.eu/fdsnws/event/1/query"
            f"?format=json&starttime={start_time}&latitude={lat}&longitude={lon}"
            f"&maxradius={max_radius_degrees:.4f}&minmagnitude={min_magnitude}"
        )
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(url, timeout=self.reqtimeout)
        except Exception:
            errors.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    " Cannot get earthquakes (EMSC). Will try again on the next refresh.",
                ]
            )
            return raw_quakes, errors

        self.logger.debug(f"RESPONSE={pf(response)}")
        # EMSC returns HTTP 204 (No Content) when nothing matches - that's
        # not an error, just an empty result.
        if response.status_code != 200:
            return raw_quakes, errors

        data = response.json()

        for feature in data.get("features", []):
            properties: dict = feature["properties"]

            quake_time: datetime = datetime.fromisoformat(
                properties["time"].replace("Z", "+00:00")
            )

            raw_quakes.append(
                {
                    "source": "EMSC",
                    "time": quake_time,
                    "datetime": quake_time.strftime("%Y-%m-%d %H:%M") + "UCT",
                    "magnitude": float(properties["mag"]),
                    "lat": float(properties["lat"]),
                    "lon": float(properties["lon"]),
                    "depth": float(properties.get("depth") or 0.0),
                    "place": properties.get("flynn_region") or "Unknown location",
                    "mmi": None,  # EMSC doesn't supply a measured intensity
                    "tsunami": False,  # EMSC doesn't supply a tsunami flag
                    "alert": "",  # EMSC doesn't supply a PAGER-style alert
                }
            )

        return raw_quakes, errors

    def _dedupe_quakes(self, raw_quakes: list[dict]) -> list[dict]:
        """Merges duplicate reports of the same physical earthquake seen
        from multiple sources (USGS and EMSC very often both report the
        same event, each with its own slightly different magnitude and
        epicenter estimate).

        Two records are treated as the same event if they're close in
        time, close in space, and reasonably close in magnitude. The
        tolerances below are generous on purpose - different networks
        commonly disagree on the exact origin time, location and
        magnitude of the very same earthquake.
        """

        TIME_TOLERANCE_SECONDS: int = 120
        DISTANCE_TOLERANCE_KM: float = 50.0
        MAGNITUDE_TOLERANCE: float = 1.0

        merged: list[dict] = []

        for quake in raw_quakes:
            match: dict | None = None

            for existing in merged:
                time_diff: float = abs(
                    (quake["time"] - existing["time"]).total_seconds()
                )
                if time_diff > TIME_TOLERANCE_SECONDS:
                    continue

                distance: float = self._haversine(
                    quake["lat"], quake["lon"], existing["lat"], existing["lon"]
                )
                if distance > DISTANCE_TOLERANCE_KM:
                    continue

                if (
                    abs(quake["magnitude"] - existing["magnitude"])
                    > MAGNITUDE_TOLERANCE
                ):
                    continue

                match = existing
                break

            if match is None:
                quake["sources"] = [quake["source"]]
                merged.append(quake)
                continue

            # Same physical event - fold it into the existing record
            # rather than reporting it twice.
            if quake["source"] not in match["sources"]:
                match["sources"].append(quake["source"])

            if match["mmi"] is None and quake["mmi"] is not None:
                match["mmi"] = quake["mmi"]

            # Prefer whichever report gives the larger magnitude and its
            # matching place name, since that's usually the more complete
            # / more reviewed estimate.
            if quake["magnitude"] > match["magnitude"]:
                match["magnitude"] = quake["magnitude"]
                match["place"] = quake["place"]

            match["tsunami"] = match["tsunami"] or quake["tsunami"]
            if not match["alert"] and quake["alert"]:
                match["alert"] = quake["alert"]

        return merged

    import re

    def _shorten_directions(self, text: str) -> str:
        """Shorten uppercase geographical direction words to abbreviations,
        e.g. 'NORTHWEST' -> 'NW.', 'SOUTH' -> 'S.', with no space after the dot.
        """

        directions = {
            "NORTHEAST": "NE.",
            "NORTHWEST": "NW.",
            "SOUTHEAST": "SE.",
            "SOUTHWEST": "SW.",
            "NORTH": "N.",
            "SOUTH": "S.",
            "EAST": "E.",
            "WEST": "W.",
        }

        # Longest keys first, so "NORTHWEST" is matched before "NORTH"/"WEST"
        pattern = re.compile(
            r"\b(" + "|".join(sorted(directions, key=len, reverse=True)) + r")\b\s*"
        )

        return pattern.sub(lambda m: directions[m.group(1)], text)

    def get_detailed_quakes(
        self, lat: str, lon: str, continent_code: str | None, timezone: str
    ) -> list[list[str]]:
        """Pulls earthquakes from USGS, and additionally from EMSC when the
        location is in Europe, starting midnight of the previous day.
        Duplicate reports of the same event are merged. Each quake is
        labeled with how strongly it's expected to be felt on the surface.

        continent_code is the two-letter continent code from the config
        (e.g. "EU", "AS", "AF", ...). If it's missing, empty, or "XX"
        (the sentinel used for manually-entered, ungeocoded locations),
        we have no reliable way to judge which sources even apply here,
        so we skip earthquake monitoring entirely for this location.
        """

        if not continent_code or continent_code.upper() == "XX":
            return []

        # self.logger.info("** Querying USGS and EMSC for earthquakes **")

        max_distance_km: float = 220.0
        min_magnitude: float = 2.0

        start_of_yesterday: date = (datetime.now() - timedelta(days=1)).replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        start_time: str = start_of_yesterday.isoformat()

        raw_quakes: list[dict] = []
        errors: list[list[str]] = []

        usgs_quakes, usgs_errors = self._get_usgs_quakes(
            lat, lon, start_time, max_distance_km, min_magnitude
        )
        raw_quakes.extend(usgs_quakes)
        errors.extend(usgs_errors)

        # EMSC only usefully covers Europe and the Mediterranean basin.
        # Continent codes are too coarse to detect "Mediterranean"
        # precisely (e.g. Turkey sits in "AS" and Egypt in "AF", right
        # alongside countries nowhere near the Mediterranean), so this is
        # deliberately conservative and only queries EMSC for "EU". If you
        # regularly want EMSC coverage for a Mediterranean-adjacent
        # non-European home location, this check would need to move to a
        # country-code list instead.
        if continent_code.upper() == "EU":
            emsc_quakes, emsc_errors = self._get_emsc_quakes(
                lat, lon, start_time, max_distance_km, min_magnitude
            )
            raw_quakes.extend(emsc_quakes)
            errors.extend(emsc_errors)

        merged_quakes: list[dict] = self._dedupe_quakes(raw_quakes)

        quakes: list[list[str]] = list(errors)
        for quake in merged_quakes:
            distance: float = self._haversine(
                float(lat), float(lon), quake["lat"], quake["lon"]
            )
            mmi: float = (
                quake["mmi"]
                if quake["mmi"] is not None
                else self._estimate_mmi(quake["magnitude"], distance)
            )
            felt: str = self._felt_category(mmi)

            plane: str = "UNDERWATER" if quake["depth"] < 0 else "EARTH"
            tsunami_str: str = "[TSUNAMI]" if quake["tsunami"] else ""
            alert_str: str = f"[{quake['alert'].upper()}]" if quake["alert"] else ""
            source_str: str = "+".join(quake["sources"])
            depth_str: str = f"{quake['depth']}"

            for prefix in ("REPUBLIC OF ", "KINGDOM OF ", "STATE OF "):
                if quake["place"].upper().startswith(prefix):
                    quake["place"] = quake["place"][len(prefix) :]
                    break

            quake["place"] = self._shorten_directions(quake["place"])

            local_dt = quake["time"].astimezone(ZoneInfo(timezone))
            datetime_str: str = local_dt.strftime("%Y-%m-%d %H:%M%Z")

            location: str = f"{quake['lat']},{quake['lon']}"
            message: str = (
                f"[{datetime_str}][{quake['magnitude']:.1f}M][DPT:{depth_str}KM][{felt}]{alert_str}[{plane}]{tsunami_str} "
                f"Dist: {distance:.0f}km ({quake['place']})({source_str})"
            )
            quakes.append(["DISASTER", location, "EARTHQUAKE", message])

        return quakes

    def get_fireballs(self) -> list[list[str]]:
        """Querying NASA Fireballs"""

        self.logger.info("** Querying NASA JPL **")

        fireballs: list[list[str]] = []

        start_of_yesterday: date = (datetime.now() - timedelta(days=1)).replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        url: str = (
            "https://ssd-api.jpl.nasa.gov/fireball.api?date-min="
            + start_of_yesterday.strftime("%Y-%m-%d")
        )
        self.logger.debug(f"URL={url}")

        try:
            response = requests.get(url, timeout=self.reqtimeout)
        except Exception:
            fireballs.append(
                [
                    "ERROR",
                    "ALL",
                    "ERROR",
                    " Cannot get space falling objects data (FIREBALLS). Will try again on the next refresh.",
                ]
            )
            return fireballs

        self.logger.debug(f"RESPONSE={pf(response)}")
        if response.status_code != 200:
            return []

        data: requests.Response = response.json()

        if data["count"] == "0":
            return []

        f: list[str] = data["fields"]
        for datum in data["data"]:
            date: str = datum[f.index("date")]
            # energy: str = datum[f.index("energy")]
            impacte: float = float(datum[f.index("impact-e")])
            altitude: str = datum[f.index("alt")]

            if "vel" not in f:
                current_fields: str = ",".join(f)
                raise Exception(
                    f"'vel' no longer in the NASA FIREBALLS API response. Please update code. Current fields: {current_fields}"
                )

            velocity: str = datum[f.index("vel")]

            if velocity is None:
                velocity = "UNKNOWN"

            size: str = "MINOR FIREBALL"
            homeremote: str = "REMOTE"
            if 0.1 <= impacte <= 1:
                size = "MODERATE BOLIDE"
            elif 1.1 <= impacte <= 10:
                size = "SEVERE AIRBURST"
                homeremote = "EMERGENCY"
            elif impacte > 10:
                size = "CATASTROPHE!!!"
                homeremote = "DISASTER"

            message: str = (
                f"[{date}][SIZE: {size}] Altitude: {altitude}km, velocity: {velocity}km/s, impact-e: {impacte}kt."
            )
            fireballs.append([homeremote, "SPACE", "FIREBALL", message])

        return fireballs


class ElectrostaticAdvisor:
    def get_xray_flux(self, reqtimeout: int) -> str:
        """Fetches the current Solar X-ray flux class (e.g., 'B2.1', 'M5.0').
        Source: NOAA GOES Primary X-ray Flux
        """

        logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )
        logger.info("** Querying SWPC NOAA (x-rays) **")

        # This endpoint provides the 1-minute data for the last 24 hours
        url = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
        logger.debug(f"URL={url}")
        try:
            response = requests.get(url, timeout=reqtimeout)
            logger.debug(f"RESPONSE={pf(response)}")
            if response.status_code == 200:
                data: requests.Response = response.json()
                # Get the very last measurement
                latest = data[-1]
                flux_value = latest.get("flux", 0.0)

                # Convert numeric flux to Class (A, B, C, M, X)
                # 1e-8 = A, 1e-7 = B, 1e-6 = C, 1e-5 = M, 1e-4 = X
                if flux_value < 1e-7:
                    return f"A{flux_value/1e-8:.1f}"
                elif flux_value < 1e-6:
                    return f"B{flux_value/1e-7:.1f}"
                elif flux_value < 1e-5:
                    return f"C{flux_value/1e-6:.1f}"
                elif flux_value < 1e-4:
                    return f"M{flux_value/1e-5:.1f}"
                else:
                    return f"X{flux_value/1e-4:.1f}"
        except Exception:
            logger.error(
                "Cannot get the X-ray flux (SWPC NOAA). Will try again on the next refresh."
            )

        return "B1.0"  # Default/Quiet background

    def get_electrostatic_warning(self, xray_flux: str) -> str:
        """Evaluates risk for electrosensitivity"""

        if not xray_flux:
            return ""

        class_letter = xray_flux[0].upper()

        if class_letter == "M":
            return f"[RISK] Sol.XrayFlux: {xray_flux} (M-Cls). Discomfrt/'phantom' skin sensations for sensitive people."
        elif class_letter == "X":
            return f"[RISK] Sol.XrayFlux: {xray_flux} (X-Cls). Neurolog.discomfrt, dizziness,localized pain."

        return ""


class HolidaysManager:
    """Taking care of the national holidays"""

    def _need_update(self, year: str, code1: str, code2: str) -> bool:
        this_year: str = str(datetime.now().year)
        if this_year != year or code1 != code2:
            return True
        return False

    def update(
        self, holidays: dict[str, str], country_code2: str, reqtimeout: int
    ) -> dict[str, str]:
        """Method to update the holidays data.

        However no update would be needed if the holidays year is the same as
        today's year and the holidays country code is the same as our
        HOME's country code.
        """

        logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )
        logger.info("** Querying Holidays **")

        new_holidays: dict[str, str] = {}

        # Fix for bug when a random Earth point was set as Home location
        if country_code2 in ["", "XX"]:
            return new_holidays

        # Holidays data
        year: str = ""
        code1: str = ""
        separator: str = "#"

        if len(holidays) > 0:
            year = list(holidays.keys())[0].split("-")[0]
            code1 = holidays[list(holidays.keys())[0]].split(separator)[0]

            if not self._need_update(year, code1, country_code2):
                return holidays

        if len(year) == 0:
            year = datetime.now().year

        url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country_code2.upper()}"
        logger.debug(f"URL={url}")

        try:
            data = requests.get(url, timeout=reqtimeout).json()
        except Exception:
            # Just making it more user-friendly
            raise Exception(
                f"Cannot get public holidays. Try again in a minute. Request timeout ({reqtimeout})."
            )

        logger.debug(f"JSONDATA={pf(data)}")
        for holiday in data:
            new_holidays[holiday["date"]] = (
                f"{holiday['countryCode']}{separator}{holiday['name']}"
            )

        return new_holidays


class CacheManager:
    r"""For when restarting the app too often
    and avoid being banned by the APIs
    """

    def __init__(self) -> None:
        self.filename: Path = Path(tempfile.gettempdir()) / "tuiweathergirl_cache.pkl"
        self.filename = self.filename.resolve()
        self.loaded: bool = False
        self._data: dict[str, any] = {}
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

    def register(self, name: str, instance: any) -> None:
        """Register a client object that needs caching."""
        self._data[name] = instance

    @property
    def too_soon(self):
        if not self.filename.exists():
            return False

        mtime = self.filename.stat().st_mtime
        last_modified_date = datetime.fromtimestamp(mtime).astimezone()
        now: datetime = datetime.now().astimezone()

        return now - last_modified_date < timedelta(minutes=REFRESH_INTERVAL)

    @property
    def saved(self) -> bool:
        return self.filename.exists()

    def load(self) -> None:
        r"""Reads the cache"""

        if len(self._data) == 0:
            raise Exception("Cannot load cache: No registered client objects.")
        if not self.saved:
            raise Exception("Tried to load unsaved cache.")

        with open(self.filename, "rb") as f:
            payload: dict[str, any] = pickle.load(f)

        for name, cached in payload.items():
            if name in self._data:
                # self._data[name].__dict__.update(cached)
                self._data[name].__dict__.clear()
                self._data[name].__dict__.update(cached.__dict__)
            else:
                raise Exception(
                    f"Client '{name}' was cached but not registered to be loaded."
                )

        self.loaded = True
        self.logger.info("Cache loaded")

    def save(self) -> None:
        """Saves the cache."""

        if self.too_soon:
            return

        # I don't care of a previous cache
        self.filename.unlink(missing_ok=True)

        payload: dict[str, any] = {
            name: instance for name, instance in self._data.items()
        }
        with open(self.filename, "wb") as f:
            pickle.dump(payload, f)

        self.logger.info("Cache saved")

    def delete(self) -> None:
        """Deletes the cache."""
        self.filename.unlink(missing_ok=True)
        self.logger.info("Cache deleted")


class Configuration:
    r"""Weather configuration"""

    def __init__(self) -> None:
        self._country: str = ""
        self._country_code2: str = ""
        self._city: str = ""
        self.postal_code: str = ""
        self.province: str = ""
        self.lat: str = ""
        self.lon: str = ""
        self.continent_code: str = ""
        self.timezone: str = ""
        self.language: str = ""
        # ---
        self.time24: bool = True
        self.metric: bool = True
        self.celsius: bool = True
        self.date_format_length: str = "medium"
        self.view: str = DEFAULT_VIEW
        self.theme: str = DEFAULT_THEME
        self.reqtimeout: int = REQTIMEOUT
        # Application update mechanics
        self.last_appupdate_check: str = ""
        self.last_known_appversion: str = __version__
        self.appautoupdate: bool = True
        self.cities_sorting: str = "unsorted"

        self.followcities: list[dict[str | int]] = []

        self.holidays: dict[str, str] = {}

        self.filename: str | Path = Path.home() / ".tuiweathergirlrc"

        if os.name == "nt":
            self.filename = Path.home() / "tuiweathergirl.ini"

        self.filename = self.filename.resolve()

        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

    @property
    def country(self) -> str:
        return self._country

    @country.setter
    def country(self, s: str) -> None:
        self._country = s.title()

        # Proper abbreviations support
        if self._country.upper() in ["USA", "US", "UK", "UAE", "CAR", "DRC"]:
            self._country = self._country.upper()

    @property
    def country_code2(self) -> str:
        return self._country_code2

    @country_code2.setter
    def country_code2(self, s: str) -> None:
        self._country_code2 = s.upper()

    @property
    def countries_of_interest(self) -> list[str]:
        """Returns only the 2-letter country codes"""

        countries: list[str] = [self.country_code2]
        for city in self.followcities:
            countries.append(city["country_code2"])

        return countries

    @property
    def city(self) -> str:
        return self._city

    @city.setter
    def city(self, s: str) -> None:
        self._city = s.title().replace(" Stn", " STN")

    def __str__(self) -> str:
        return f"{self.city}/{self.province}/{self.country}/{self.continent_code}"

    def __repr__(self) -> str:
        s: str = f"""CONFIGURATION OBJECT:
City: {self.city}/{self.province}/{self.country}({self.country_code2})/{self.continent_code}
Postal code: {self.postal_code}
Coordinates: {self.lat},{self.lon}
Primary language: {self.language}
Timezone: {self.timezone}"""
        s += "Time: 24h\n" if self.time24 else "Time: 12h\n"
        s += "Speed units: Metric\n" if self.metric else "Speed units: Imperial\n"
        s += (
            "Temperature units: Celsius\n"
            if self.celsius
            else "Temperature units: Fahrenheit\n"
        )
        s += f"Date format length: {self.date_format_length}\n"
        s += f"Default view: {self.view}\n"
        s += f"Default theme: {self.theme}\n"
        s += f"Filename: {self.filename}\n"

        s += "\nFOLLOWED CITIES:\n"
        s += pf(self.followcities)
        s += "\n"

        return s

    @property
    def dst(self) -> bool:
        if len(self.timezone) == 0:
            return False
        now: datetime = datetime.now(ZoneInfo(self.timezone))
        return now.dst().total_seconds() != 0

    @property
    def saved(self) -> bool:
        return self.filename.exists()

    def save(self) -> None:
        r"""Write the configuration to the config file"""

        config = configparser.ConfigParser()

        config["HOME"] = {
            "country": self.country,
            "country_code2": self.country_code2,
            "city": self.city,
            "postal_code": self.postal_code,
            "province": self.province,
            "lat": self.lat,
            "lon": self.lon,
            "continent_code": self.continent_code,
            "timezone": self.timezone,
        }

        config["PREFERENCES"] = {
            "language": self.language,
            "time24": str(self.time24).lower(),
            "metric": str(self.metric).lower(),
            "celsius": str(self.celsius).lower(),
            # "date_format_length": self.date_format_length,
            "view": self.view,
            "theme": self.theme,
            "requesttimeout": self.reqtimeout,
            # ---
            "lastappupdatecheck": self.last_appupdate_check,
            "lastknownappversion": self.last_known_appversion,
            "appautoupdate": str(self.appautoupdate).lower(),
            "citiessorting": str(self.cities_sorting).lower(),
        }

        config["HOLIDAYS"] = self.holidays

        for i, city in enumerate(self.followcities):
            config[f"FOLLOWCITY{i+1}"] = {
                "country": city["country"],
                "country_code2": city["country_code2"],
                "city": city["city"],
                "postal_code": str(city["postal_code"]),
                "province": str(city["province"]),
                "lat": str(city["lat"]),
                "lon": str(city["lon"]),
                "continent_code": city["continent_code"],
                "timezone": city["timezone"],
            }

        # Write to a file
        with open(self.filename, "w") as configfile:
            config.write(configfile)

        self.logger.info("Config saved")

    def load(self) -> None:
        r"""Read the configuration from the config file"""

        if not self.saved:
            raise Exception("Tried to load unsaved configuration.")

        config = configparser.ConfigParser()
        config.read(self.filename)

        self.followcities = []  # Reset the list

        self.country = config["HOME"]["country"]
        self.country_code2 = config["HOME"]["country_code2"]
        self.city = config["HOME"]["city"]
        self.postal_code = config["HOME"]["postal_code"]
        self.province = config["HOME"]["province"]
        self.lat = config["HOME"]["lat"]
        self.lon = config["HOME"]["lon"]
        self.continent_code = config["HOME"]["continent_code"]
        self.timezone = config["HOME"]["timezone"]
        self.language = config["PREFERENCES"]["language"]
        self.time24 = config.getboolean("PREFERENCES", "time24", fallback=True)
        self.metric = config.getboolean("PREFERENCES", "metric", fallback=True)
        self.celsius = config.getboolean("PREFERENCES", "celsius", fallback=True)
        # self.date_format_length = config["PREFERENCES"]["date_format_length"]
        self.view = config["PREFERENCES"]["view"]
        self.theme = config["PREFERENCES"]["theme"]
        self.reqtimeout = int(config["PREFERENCES"]["requesttimeout"])
        # ---
        self.last_appupdate_check = (
            config["PREFERENCES"].get("lastappupdatecheck") or ""
        )
        self.last_known_appversion = (
            config["PREFERENCES"].get("lastknownappversion") or __version__
        )
        self.appautoupdate = config.getboolean(
            "PREFERENCES", "appautoupdate", fallback=True
        )
        self.cities_sorting = config["PREFERENCES"].get("citiessorting") or "unsorted"

        if self.cities_sorting not in ["unsorted", "city", "country"]:
            raise ValueError(
                f"Invalid value of '{self.cities_sorting}' for key 'citiessorting' in the config file. Valid: unsorted, city, country."
            )

        self.holidays = dict(config["HOLIDAYS"])

        # Let me be clear here: In case someone manually adds extra cities
        # we will truncate the list.

        for i in range(40):
            index: str = f"FOLLOWCITY{i+1}"
            if index in config:
                city: dict[str | int] = {
                    "country": config[index]["country"],
                    "country_code2": config[index]["country_code2"],
                    "city": config[index]["city"],
                    "postal_code": config[index]["postal_code"],
                    "province": config[index]["province"],
                    "lat": config[index]["lat"],
                    "lon": config[index]["lon"],
                    "continent_code": config[index]["continent_code"],
                    "timezone": config[index]["timezone"],
                }
                self.followcities.append(city)

        if len(self.followcities) > MAX_CITIES - 1:
            del self.followcities[MAX_CITIES - 1 :]
            self.save()

        self.logger.info("Config loaded")

    def follow_city(
        self,
        city: str,
        country: str,
        lat: float | None = None,
        lon: float | None = None,
    ) -> None:
        country = country.strip()
        city = city.strip()

        for city_entry in self.followcities:
            if (
                city.upper() == city_entry["city"].upper()
                and country.upper() == city_entry["country"].upper()
            ):
                raise Exception(f"City '{city}/{country}' is already followed.")

        if len(self.followcities) > MAX_CITIES - 2:
            raise Exception(f"Cannot follow city. Max cities limit is {MAX_CITIES-1}.")

        city_entry: dict[str | int] = {
            "country": country,
            "city": city,
        }

        if country.lower() == "polarstation":
            station_data: list[str] = polar_stations(int(city))
            sname, scountry, slat, slon, scontinent, stimezone, sccode2 = station_data

            city_entry = {
                "country": scountry,
                "city": sname,
                "lat": slat,
                "lon": slon,
                "continent_code": scontinent,
                "timezone": stimezone,
                "country_code2": sccode2,
                "postal_code": "",
                "province": "",
            }

        if lat and lon:
            city_entry = {
                "country": country,
                "city": city,
                "lat": lat,
                "lon": lon,
                "continent_code": "",
                "timezone": "",
                "country_code2": "XX",  # Safe for use dummy country code
                "postal_code": "",
                "province": "",
            }

        self.followcities.append(city_entry)


class UpdateManager:
    """Checking for updates, download updates etc."""

    def __init__(self, config: Configuration) -> None:
        self.maxdays: int = 9
        self.mindays: int = 3
        self.minminutes: int = 2  # Let's not kill github
        self.update_dow: int = 2  # Wednesday
        self.config: Configuration = config
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

    def _is_system_package(self) -> bool:
        """Tests if application was installed by the system package manager"""

        # Resolves the true location of the running script
        application_path = Path(__file__).resolve()

        # Check if installed inside system directories (/usr/bin, /usr/local/bin, /bin, etc.)
        return application_path.as_posix().startswith(
            "/usr"
        ) or application_path.as_posix().startswith("/bin")

    def _must_autoupdate(self) -> bool:
        """Returns True if application must auto-update"""

        # If never checked - check imediatelly
        if not self.config.last_appupdate_check:
            return True

        if not self.config.appautoupdate:
            return False

        today = datetime.now(timezone.utc)

        try:
            # A long time ago in a galaxy far far away?
            last_appupdate_check = datetime.fromisoformat(
                self.config.last_appupdate_check
            )
            if (today - last_appupdate_check).days > self.maxdays:
                return True

            # It's update day! (but hey let's not update everyday okay?)
            if (
                date.today().weekday() == self.update_dow
                and (today - last_appupdate_check).days > self.mindays
            ):
                return True
        except ValueError:
            # Probably Invalid date format, so let's force it
            return True

        return False

    def _newer_version(self) -> str:
        """Checks for updates, returns the most recent version if newer than
        the current version.
        """

        self.logger.info("Checking for newer version of TUIWEATHERGIRL...")

        url = "https://api.github.com/repos/StrayFeral/tuiweathergirl/releases/latest"

        try:
            response = requests.get(url, headers=HTTPHEADERS, timeout=REQTIMEOUT)
            response.raise_for_status()

            data = response.json()
            latest_tag = data["tag_name"].lstrip("v")

            if parse_version(latest_tag) > parse_version(__version__):
                self.logger.info(f"Newer version found: {latest_tag}")
                return latest_tag

            self.logger.info("No newer version found.")
            return ""
        except requests.RequestException:
            self.logger.info("No newer version found.")
            return ""  # Ignore errors (e.g., offline mode)

    def _restart_application(self) -> None:
        """Restart current application."""

        self.logger.info("*** RESTARTING APPLICATION ***")
        logger.info("===================================================== SESSION END")

        current_script = Path(sys.argv[0]).resolve()
        args = [a for a in sys.argv[1:] if a not in ("--updateapp")]

        try:
            if os.name == "nt":  # Windows
                subprocess.Popen([sys.executable, str(current_script)] + args)
                sys.exit(0)
            else:
                # Flushing output buffers
                sys.stdout.flush()
                sys.stderr.flush()

                cmd = [sys.executable, str(current_script)] + args
                os.execv(sys.executable, cmd)
        except Exception as e:
            print(f"Failed to restart: {e}")

    def _update(self, newer_version: str) -> None:
        if not newer_version:
            # Updating update timestamp
            now = datetime.now(timezone.utc)
            timestamp_str = now.isoformat()
            config.last_appupdate_check = timestamp_str
            config.last_known_appversion = __version__
            config.save()
            return

        download_url: str = (
            f"https://github.com/StrayFeral/tuiweathergirl/releases/download/v{newer_version}/tuiweathergirl.zip"
        )
        current_script: Path = Path(sys.argv[0]).resolve()
        temp_dir: Path = Path(tempfile.gettempdir())
        zip_path: Path = temp_dir / "tuiweathergirl.zip"
        extract_dir: Path = temp_dir / "tuiweathergirl"

        try:
            self.logger.info(f"Downloading new version: {newer_version} ...")
            zip_res: requests.Response = requests.get(
                download_url, headers=HTTPHEADERS, timeout=15
            )
            zip_res.raise_for_status()
            zip_path.write_bytes(zip_res.content)

            if extract_dir.exists():
                shutil.rmtree(extract_dir)
            extract_dir.mkdir(parents=True, exist_ok=True)

            self.logger.info("Extracting...")
            with zipfile.ZipFile(zip_path, "r") as zip_ref:
                zip_ref.extractall(extract_dir)

            extracted_files: list = list(extract_dir.rglob("tuiweathergirl.py"))
            if not extracted_files:
                raise FileNotFoundError(
                    "tuiweathergirl.py not found inside release archive!"
                )
            extracted_script = extracted_files[0]

            self.logger.info("Installing...")
            shutil.move(str(extracted_script), str(current_script))

            # Set executable permissions
            if os.name != "nt":
                current_permissions = current_script.stat().st_mode
                current_script.chmod(
                    current_permissions | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
                )

            self.logger.info("Cleanup...")
            zip_path.unlink(missing_ok=True)
            shutil.rmtree(extract_dir, ignore_errors=True)

            # Updating update timestamp
            now = datetime.now(timezone.utc)
            timestamp_str = now.isoformat()
            config.last_appupdate_check = timestamp_str
            config.last_known_appversion = newer_version
            config.save()

            self.logger.info(f"UPDATE COMPLETE. Installed new version: {newer_version}")
            self._restart_application()

        except Exception as e:
            self.logger.exception(f"Failed to update to version {newer_version}: {e}")

    def force_update(self) -> None:
        """Force update the application"""

        if self._is_system_package():
            raise Exception(
                "Cannot update. Application was installed as a system package. Run with --updatepolicy for details."
            )

        try:
            today = datetime.now(timezone.utc)
            last_appupdate_check = datetime.fromisoformat(
                self.config.last_appupdate_check
            )
            elapsed_minutes = int((today - last_appupdate_check).total_seconds() // 60)

            if elapsed_minutes < self.minminutes:
                raise Exception(
                    f"Don't force-update too often. Please wait {self.minminutes - elapsed_minutes} more minute(s). Thanks!"
                )
        except ValueError:
            # Probably Invalid date format, so let's force it
            pass

        latest_release: str = self._newer_version()
        self._update(latest_release)

    def auto_update(self) -> None:
        """Application auto-update"""

        if self._is_system_package():
            raise Exception(
                "Cannot update. Application was installed as a system package. Run with --updatepolicy for details."
            )

        if not self._must_autoupdate():
            return

        latest_release: str = self._newer_version()
        self._update(latest_release)


class Locator:
    r"""Gets location data"""

    __CANADA_PROVINCES: dict[str, str] = {
        "alberta": "AB",
        "british columbia": "BC",
        "manitoba": "MB",
        "new brunswick": "NB",
        "newfoundland and labrador": "NL",
        "northwest territories": "NT",
        "nova scotia": "NS",
        "nunavut": "NU",
        "ontario": "ON",
        "prince edward island": "PE",
        "quebec": "QC",
        "saskatchewan": "SK",
        "yukon": "YT",
    }

    # __US_STATES: dict[str, str] = {
    #     "alabama": "AL",
    #     "alaska": "AK",
    #     "arizona": "AZ",
    #     "arkansas": "AR",
    #     "california": "CA",
    #     "colorado": "CO",
    #     "connecticut": "CT",
    #     "delaware": "DE",
    #     "florida": "FL",
    #     "georgia": "GA",
    #     "hawaii": "HI",
    #     "idaho": "ID",
    #     "illinois": "IL",
    #     "indiana": "IN",
    #     "iowa": "IA",
    #     "kansas": "KS",
    #     "kentucky": "KY",
    #     "louisiana": "LA",
    #     "maine": "ME",
    #     "maryland": "MD",
    #     "massachusetts": "MA",
    #     "michigan": "MI",
    #     "minnesota": "MN",
    #     "mississippi": "MS",
    #     "missouri": "MO",
    #     "montana": "MT",
    #     "nebraska": "NE",
    #     "nevada": "NV",
    #     "new hampshire": "NH",
    #     "new jersey": "NJ",
    #     "new mexico": "NM",
    #     "new york": "NY",
    #     "north carolina": "NC",
    #     "north dakota": "ND",
    #     "ohio": "OH",
    #     "oklahoma": "OK",
    #     "oregon": "OR",
    #     "pennsylvania": "PA",
    #     "rhode island": "RI",
    #     "south carolina": "SC",
    #     "south dakota": "SD",
    #     "tennessee": "TN",
    #     "texas": "TX",
    #     "utah": "UT",
    #     "vermont": "VT",
    #     "virginia": "VA",
    #     "washington": "WA",
    #     "west virginia": "WV",
    #     "wisconsin": "WI",
    #     "wyoming": "WY",
    # }

    __LONG_COUNTRY_NAMES: dict[str, str] = {
        "guyana": "Guyana",
        "democratic people's republic of korea": "North Korea",
        "republic of korea": "South Korea",
        "são tomé and príncipe": "São Tomé",
        "sao tome and príncipe": "São Tomé",
        "timor": "Timor-Leste",
        "leste": "Timor-Leste",
        "congo": "Congo",
        "sri lanka": "Sri Lanka",
        "ethiopia": "Ethiopia",
        "nepal": "Nepal",
        "saint kitts and nevis": "Saint Kitts",
        "micronesia": "Micronesia",
        "papua new guinea": "Papua New Guinea",
        "samoa": "Samoa",
        "afghanistan": "Afghanistan",
        "mauritania": "Mauritania",
        "pakistan": "Pakistan",
        "lao": "Lao",
        "algeria": "Algeria",
        "bolivia": "Bolivia",
        "trinidad and tobago": "Trinidad",
        "marshall islands": "Marshall Islands",
        "vietnam": "Vietnam",
        "comoros": "Comoros",
        "united arab emirates": "UAE",
        "united kingdom": "UK",
        "great britain": "UK",
        "northern ireland": "UK",
        "tanzania": "Tanzania",
        "united states": "USA",
        "venezuela": "Venezuela",
        "central african republic": "CFA",
        "dominican republic": "Dominican Rep",
        "syria": "Syria",
        "bahamas": "Bahamas",
        "luxembourg": "Luxembourg",
        "jordan": "Jordan",
        "kiribati": "Kiribati",
        "iran": "Iran",
        "saudi arabia": "Saudi Arabia",
        "brunei": "Brunei",
        "uruguay": "Uruguay",
        "liechtenstein": "Liechtenstein",
        "equatorial guinea": "Equatorial Guinea",
        "madagascar": "Madagascar",
        "mozambique": "Mozambique",
        "north macedonia": "North Macedonia",
        "san marino": "San Marino",
        "south africa": "South Africa",
        "philippines": "Philippines",
        "saint vincent": "Saint Vincent",
        "fiji": "Fiji",
        "swiss": "Switzerland",
        "argentine republic": "Argentina",
        "guernsey": "Guernsey",
        "jersey": "Jersey",
        "germany": "Germany",
        "nigeria": "Nigeria",
        "somalia": "Somalia",
        "brazil": "Brazil",
        "swaziland": "Swaziland",
        "netherlands": "Netherlands",
        "côte d'ivoire": "Cote d'Ivoire",
        "cote d'ivoire": "Cote d'Ivoire",
        "malta": "Malta",
        "bosnia and herzegovina": "Bosnia",
        "herzegovina": "Bosnia",
        "iceland": "Iceland",
    }

    # fmt: off
    __CONTINENT_MAP: dict[str, list[str]] = {
        "AF": [
            "DZ", "AO", "BJ", "BW", "BF", "BI", "CV", "CM", "CF", "TD", "KM",
            "CD", "CG", "CI", "DJ", "EG", "GQ", "ER", "SZ", "ET", "GA", "GM",
            "GH", "GN", "GW", "KE", "LS", "LR", "LY", "MG", "MW", "ML", "MR",
            "MU", "MA", "MZ", "NA", "NE", "NG", "RW", "ST", "SN", "SC", "SL",
            "SO", "ZA", "SS", "SD", "TZ", "TG", "TN", "UG", "ZM", "ZW",
        ],
        "AS": [
            "AF", "AM", "AZ", "BH", "BD", "BT", "BN", "KH", "CN", "CY", "GE",
            "IN", "ID", "IR", "IQ", "IL", "JP", "JO", "KZ", "KW", "KG", "LA",
            "LB", "MY", "MV", "MN", "MM", "NP", "KP", "OM", "PK", "PS", "PH",
            "QA", "SA", "SG", "KR", "LK", "SY", "TW", "TJ", "TH", "TL", "TR",
            "TM", "AE", "UZ", "VN", "YE",
        ],
        "EU": [
            "AL", "AD", "AT", "BY", "BE", "BA", "BG", "HR", "CZ", "DK", "EE",
            "FI", "FR", "DE", "GR", "HU", "IS", "IE", "IT", "LV", "LI", "LT",
            "LU", "MT", "MD", "MC", "ME", "NL", "MK", "NO", "PL", "PT", "RO",
            "RU", "SM", "RS", "SK", "SI", "ES", "SE", "CH", "UA", "GB", "VA",
        ],
        "NA": [
            "AG", "BS", "BB", "BZ", "CA", "CR", "CU", "DM", "DO", "SV", "GD",
            "GT", "HT", "HN", "JM", "MX", "NI", "PA", "KN", "LC", "VC", "TT",
            "US",
        ],
        "OC": [
            "AU", "FJ", "KI", "MH", "FM", "NR", "NZ", "PW", "PG", "WS", "SB",
            "TO", "TV", "VU",
        ],
        "SA": [
            "AR", "BO", "BR", "CL", "CO", "EC", "GY", "PY", "PE", "SR", "UY",
            "VE",
        ],
        "AN": ["AQ"],
    }

    __ISO3_TO_ISO2 = {
        "AFG": "AF", "ALB": "AL", "DZA": "DZ", "AND": "AD", "AGO": "AO",
        "ATG": "AG", "ARG": "AR", "ARM": "AM", "AUS": "AU", "AUT": "AT",
        "AZE": "AZ", "BHS": "BS", "BHR": "BH", "BGD": "BD", "BRB": "BB",
        "BLR": "BY", "BEL": "BE", "BLZ": "BZ", "BEN": "BJ", "BTN": "BT",
        "BOL": "BO", "BIH": "BA", "BWA": "BW", "BRA": "BR", "BRN": "BN",
        "BGR": "BG", "BFA": "BF", "BDI": "BI", "CPV": "CV", "KHM": "KH",
        "CMR": "CM", "CAN": "CA", "CAF": "CF", "TCD": "TD", "CHL": "CL",
        "CHN": "CN", "COL": "CO", "COM": "KM", "COG": "CG", "COD": "CD",
        "CRI": "CR", "CIV": "CI", "HRV": "HR", "CUB": "CU", "CYP": "CY",
        "CZE": "CZ", "DNK": "DK", "DJI": "DJ", "DMA": "DM", "DOM": "DO",
        "ECU": "EC", "EGY": "EG", "SLV": "SV", "GNQ": "GQ", "ERI": "ER",
        "EST": "EE", "SWZ": "SZ", "ETH": "ET", "FJI": "FJ", "FIN": "FI",
        "FRA": "FR", "GAB": "GA", "GMB": "GM", "GEO": "GE", "DEU": "DE",
        "GHA": "GH", "GRC": "GR", "GRD": "GD", "GTM": "GT", "GIN": "GN",
        "GNB": "GW", "GUY": "GY", "HTI": "HT", "HND": "HN", "HUN": "HU",
        "ISL": "IS", "IND": "IN", "IDN": "ID", "IRN": "IR", "IRQ": "IQ",
        "IRL": "IE", "ISR": "IL", "ITA": "IT", "JAM": "JM", "JPN": "JP",
        "JOR": "JO", "KAZ": "KZ", "KEN": "KE", "KIR": "KI", "KOR": "KR",
        "KWT": "KW", "KGZ": "KG", "LAO": "LA", "LVA": "LV", "LBN": "LB",
        "LSO": "LS", "LBR": "LR", "LBY": "LY", "LIE": "LI", "LTU": "LT",
        "LUX": "LU", "MDG": "MG", "MWI": "MW", "MYS": "MY", "MDV": "MV",
        "MLI": "ML", "MLT": "MT", "MHL": "MH", "MRT": "MR", "MUS": "MU",
        "MEX": "MX", "FSM": "FM", "MDA": "MD", "MCO": "MC", "MNG": "MN",
        "MNE": "ME", "MAR": "MA", "MOZ": "MZ", "MMR": "MM", "NAM": "NA",
        "NRU": "NR", "NPL": "NP", "NLD": "NL", "NZL": "NZ", "NIC": "NI",
        "NER": "NE", "NGA": "NG", "MKD": "MK", "NOR": "NO", "OMN": "OM",
        "PAK": "PK", "PLW": "PW", "PAN": "PA", "PNG": "PG", "PRY": "PY",
        "PER": "PE", "PHL": "PH", "POL": "PL", "PRT": "PT", "QAT": "QA",
        "ROU": "RO", "RUS": "RU", "RWA": "RW", "KNA": "KN", "LCA": "LC",
        "VCT": "VC", "WSM": "WS", "SMR": "SM", "STP": "ST", "SAU": "SA",
        "SEN": "SN", "SRB": "RS", "SYC": "SC", "SLE": "SL", "SGP": "SG",
        "SVK": "SK", "SVN": "SI", "SLB": "SB", "SOM": "SO", "ZAF": "ZA",
        "SSD": "SS", "ESP": "ES", "LKA": "LK", "SDN": "SD", "SUR": "SR",
        "SWE": "SE", "CHE": "CH", "SYR": "SY", "TWN": "TW", "TJK": "TJ",
        "TZA": "TZ", "THA": "TH", "TLS": "TL", "TGO": "TG", "TON": "TO",
        "TTO": "TT", "TUN": "TN", "TUR": "TR", "TKM": "TM", "TUV": "TV",
        "UGA": "UG", "UKR": "UA", "ARE": "AE", "GBR": "GB", "USA": "US",
        "URY": "UY", "UZB": "UZ", "VUT": "VU", "VEN": "VE", "VNM": "VN",
        "YEM": "YE", "ZMB": "ZM", "ZWE": "ZW",
    }
    # fmt: on

    def __get_short_country(self, country: str) -> str:
        """Shortens the country name"""
        for c in self.__LONG_COUNTRY_NAMES:
            if c in country.lower():
                return self.__LONG_COUNTRY_NAMES[c]

        # Other countries
        return country[:10]

    def __get_short_province(self, province: str) -> str:
        """Shortens the province name"""
        # Russia
        if " oblast" in province.lower():
            idx = province.lower().find(" oblast")
            return province[:idx]

        # Canada
        if province.lower() in self.__CANADA_PROVINCES:
            return self.__CANADA_PROVINCES[province.lower()]

        # NOTE: When we abbreviate "United States" to "USA"
        # these are not a problem
        # # USA
        # if province.lower() in self.__US_STATES:
        #     return self.__US_STATES[province.lower()]

        # Other countries
        return province[:17]

    def __get_short_city(self, city: str) -> str:
        """Shortens the city name"""
        return city[:17]

    def __init__(self) -> None:
        self.tzapi_calls: int = 0  # Counts the TZ data API calls
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

    def convert_to_country_code2(self, country_code3: str):
        """Converts a 3-letter country code to a 2-letter country code"""
        if country_code3 not in self.__ISO3_TO_ISO2:
            raise ValueError(f"Unknown country code '{country_code3}'.")
        return self.__ISO3_TO_ISO2[country_code3]

    def __get_continent_code(self, country: str) -> str:
        return next(
            (key for key, values in self.__CONTINENT_MAP.items() if country in values),
            "Unknown",
        )

    def _get_city_details(self, city: str, country: str) -> dict:
        # Attempt to get information for the city and the country

        self.logger.info("** Querying OpenStreetMap **")

        base_url: str = "https://nominatim.openstreetmap.org/search"
        url_params: dict[str, str] = {
            "city": city,
            "country": country,
            "format": "json",
            "addressdetails": 1,
        }
        prepared: requests.PreparedRequest = requests.PreparedRequest()
        prepared.prepare_url(base_url, url_params)
        url: str = prepared.url
        self.logger.debug(f"URL={url}")

        try:
            response = requests.get(url, headers=HTTPHEADERS, timeout=config.reqtimeout)
        except Exception:
            # Just making it more user-friendly
            raise Exception(
                f"Cannot get information for city '{city}/{country}'. Try again in a minute. Request timeout ({config.reqtimeout})."
            )

        self.logger.debug(f"RESPONSE={pf(response)}")

        if not response.ok:
            raise Exception(
                f"Location API server error. Error {response.status_code}: {APIIssues.get_api_problem(response.status_code)}"
            )

        response = response.json()

        if not response:
            raise Exception(
                f"Cannot locate city '{city}/{country}'. Please check your syntax and try again."
            )

        return response[0] or {}

    def _ip_autoconfig(self) -> dict:
        # Attempt auto-location

        logger.info("** Querying IP-API **")

        url: str = (
            "http://ip-api.com/json/?fields=status,message,continentCode,country,countryCode,region,regionName,city,zip,lat,lon,timezone"
            "&lang=en"
        )
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(url, timeout=config.reqtimeout)
        except Exception:
            # Just making it more user-friendly
            raise Exception(
                f"Cannot get computer location. Try again in a minute. Request timeout ({config.reqtimeout})."
            )

        self.logger.debug(f"RESPONSE={pf(response)}")

        if not response:
            raise Exception(
                f"Cannot autoconfigure. Error {response.status_code}: {APIIssues.get_api_problem(response.status_code)}"
            )

        response = response.json()

        if response.get("status") == "fail":
            raise Exception(f"Request failed: {response.get('message')}. Try again!")

        return response or {}

    def config(
        self,
        config: Configuration,
        city: str = "",
        country: str = "",
        lat: float | None = None,
        lon: float | None = None,
    ) -> None | dict[str | int]:
        logger.debug("Locator is running")

        if city == "" or country == "":
            data: dict = self._ip_autoconfig()
            config.country = self.__get_short_country(data.get("country"))
            config.country_code2 = data.get("countryCode")
            config.city = self.__get_short_city(data.get("city"))
            config.postal_code = data.get("zip")
            config.province = self.__get_short_province(data.get("region"))
            config.lat = data.get("lat")  # XX.XXX, Fallback plan
            config.lon = data.get("lon")  # XX.XXX
            config.timezone = data.get("timezone")
            config.continent_code = data.get("continentCode")
        else:
            # Attempt to get information for the city and the country
            city = city.title().replace(" Stn", " STN")
            country = country.title()

            if country.upper() in ["USA", "US", "UK", "UAE", "CAR", "DRC"]:
                country = country.upper()

            location: dict = self._get_city_details(city, country)
            addr: dict = location.get("address", {})
            lat: str = location.get("lat", "")
            lon: str = location.get("lon", "")

            city_entry: dict[str, str | int] = {
                "city": self.__get_short_city(
                    location.get("name", "").title().replace(" Stn", " STN")
                ),
                "country": self.__get_short_country(addr.get("country", "").title()),
                "country_code2": addr.get("country_code", "").upper(),
                "postal_code": addr.get("postcode", ""),
                "province": self.__get_short_province(
                    addr.get("state_code", addr.get("state", ""))
                ),
                "lat": lat,
                "lon": lon,
                "continent_code": self.__get_continent_code(
                    addr.get("country_code", "").upper()
                ),
                "timezone": "",
            }

            time.sleep(1)  # API requirement

            return city_entry


class Configurator:
    r"""Configures the application"""

    def __detect_language(self, country_code, province) -> str:
        exceptions = {
            "CA": {"QC": "fr"},  # Canada: Quebec
            "CH": {"TI": "it", "GE": "fr"},  # Switzerland
        }

        if country_code in exceptions and province in exceptions[country_code]:
            return exceptions[country_code][province]

        langs = get_official_languages(country_code)
        return langs[0].lower() if langs else "en"

    def __detect_metric(self, language_code, country_code) -> bool:
        loc = Locale.parse(f"{language_code}_{country_code}", sep="_")
        return loc.measurement_systems != "US"

    def __detect_celsius(self, country_code) -> bool:
        fahrenheit_countries = ["US", "BS", "KY", "LR", "PW", "MH", "FM"]
        return country_code not in fahrenheit_countries

    def __detect_time24(self, language_code, country_code) -> bool:
        locale_id = f"{language_code}_{country_code}"
        try:
            loc = Locale.parse(locale_id, sep="_")
            # Get the default short time format (e.g., "HH:mm" or "h:mm a")
            time_format = loc.time_formats["short"].pattern
            # In Unicode patterns, 'H' is 24h, 'h' is 12h
            return "H" in time_format
        except Exception:
            return True  # Default to 24h if unsure

    def config(self, c: Configuration) -> None:
        if config.country_code2 == "" or config.province == "":
            raise Exception(
                "Cannot auto-configure. Country code and province code not set."
            )

        config.language = self.__detect_language(config.country_code2, config.province)
        config.time24 = self.__detect_time24(config.language, config.country_code2)
        config.metric = self.__detect_metric(config.language, config.country_code2)
        config.celsius = self.__detect_celsius(config.country_code2)


class Motivator:
    r"""Daily motivator"""

    @staticmethod
    def get_motivation(reqtimeout: int = REQTIMEOUT) -> list[str]:
        r"""Fetches a random inspirational quote from Quotable API."""

        logger: logging.Logger = logging.getLogger("Motivator.get_motivation")
        logger.info("** Querying motivator **")

        try:
            url: str = "https://zenquotes.io/api/random"
            logger.debug(f"URL={url}")

            response = requests.get(url, timeout=reqtimeout)
            logger.debug(f"RESPONSE={pf(response)}")

            if response.status_code == 200:
                data = response.json()
                if isinstance(data, list) and len(data) > 0:
                    return [data[0].get("q"), data[0].get("a")]
        except Exception:
            logger.error(
                "Cannot get motivation, but I am super hyped and Will try again on the next refresh."
            )

        # Fallback quote in case the internet is slow or API is down
        return [
            "To appreciate the beauty of a snowflake, it is necessary to stand out in the cold.",
            "Aristotle",
        ]


class MindDrifter:
    r"""Letting your mind drift into eternity"""

    @staticmethod
    def drift() -> str:
        insomnias: list[str] = [
            "It is by walking into the forest that you unfold the steps of eternity",
            "It is the sound of the waves that brings the smell of the ocean",
            "It is the sand of the beach that tickles the voice of the seagulls",
            "It is the song of the starlings that makes the wind whisper to the ears of the hopeless",
            "It is by the will of time that we walk towards the sands of eternity",
            "It is the bloom of the ages that makes us drink the wine of wisdom",
            "It is the void of silence that makes us a whisper apart",
            "It is the fragrance of the innocence that makes the time whisper to the face of creation",
            "It is by the the well of eternity that we embrace the fate of the time",
            "It is by the blood of the roses that one hears the echoes of the untruth",
            "It is by defying the restlessness that we deny the shackles of mortality",
            "It is the sound of eternity that defines the song of the righteousness",
        ]
        return random.choice(insomnias)


class BriefDailyForecast:
    r"""For the daily forecast for the upcoming week we don't need all data"""

    def __init__(self) -> None:
        self.dow: str = ""
        self.min: int = 0
        self.max: int = 0
        self.precip: int = 0


class WeatherData:
    r"""Weather data"""

    def __init__(self) -> None:
        # Today
        self.wind_direction: str = ""
        self.sky: str = ""
        self.air_quality: str = ""
        self.aqi: int = 0
        self.temperature: int = 0
        self.wind: int = 0
        self.precipitation: int = 0
        self.precipitation_sum: int = 0
        self.min: int = 0
        self.max: int = 0
        self.weather_code: int = 0
        self.is_day: bool = True
        self.hmin: int = 0
        self.hmax: int = 0
        self.hcur: int = 0
        self.wind_type: str = ""
        self.precipitation_type: str = ""
        self.humidity_level_min: str = ""
        self.humidity_level_max: str = ""
        self.humidity: str = ""
        self.wind_direction_long: str = ""
        self.baropressure: float = 0

        # self.warnings: list[list[str]] = []
        self.week: list[BriefDailyForecast] = []
        self.cities_data: list[list[str | int]] = []
        # Preserved in original (unsorted) build order so that sorting can
        # be re-applied against the current config even when serving from
        # a cached payload, without losing the true unsorted ordering.
        self.cities_data_raw: list[list[str | int]] = []

        # This was never planned originally
        # but I decided to add it in the last moment
        self.misc_data: dict[list[any]] = {}


class WeatherForecaster:
    r"""Gets the weather forecast

    This is the Model component."""

    def __init__(self, config: Configuration) -> None:
        self.config = config
        self.locale_id = f"{config.language}_{config.country_code2}"
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )
        self.cache = CacheManager()
        self.data = WeatherData()
        self._data_collection_counter: int = 0

    def __get_wind_direction(self, degrees: float) -> str:
        # fmt: off
        dirs: list[str] = [
            "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW",
            "WSW", "W", "WNW", "NW", "NNW",
        ]
        # fmt: on
        ix = int((degrees + 11.25) / 22.5)
        return dirs[ix % 16]

    def __get_wind_direction_long(self, winddir: str) -> str:
        dirs: dict[str, str] = {
            "N": "North",
            "NNE": "North-Northeast",
            "NE": "Northeast",
            "ENE": "East-Northeast",
            "E": "East",
            "ESE": "East-Southeast",
            "SE": "Southeast",
            "SSE": "South-Southeast",
            "S": "South",
            "SSW": "South-Southwest",
            "SW": "Southwest",
            "WSW": "West-Southwest",
            "W": "West",
            "WNW": "West-Northwest",
            "NW": "Northwest",
            "NNW": "North-Northwest",
        }
        return dirs.get(winddir, "Unknown")

    def __get_wind_type(self, wind_speed: int, unit: str) -> str:
        r"""Beaufort Scale"""

        if (wind_speed < 6 and unit == "kmh") or (wind_speed < 4 and unit == "mph"):
            return "Calm"
        if (6 >= wind_speed <= 11 and unit == "kmh") or (
            4 >= wind_speed <= 7 and unit == "mph"
        ):
            return "Light Breeze"
        if (12 >= wind_speed <= 19 and unit == "kmh") or (
            8 >= wind_speed <= 12 and unit == "mph"
        ):
            return "Gentle Breeze"
        if (20 >= wind_speed <= 28 and unit == "kmh") or (
            13 >= wind_speed <= 18 and unit == "mph"
        ):
            return "Moderate Breeze"
        if (29 >= wind_speed <= 38 and unit == "kmh") or (
            19 >= wind_speed <= 24 and unit == "mph"
        ):
            return "Fresh Breeze"
        if (39 >= wind_speed <= 49 and unit == "kmh") or (
            25 >= wind_speed <= 31 and unit == "mph"
        ):
            return "Strong Breeze"
        # Yellow
        if (50 >= wind_speed <= 61 and unit == "kmh") or (
            32 >= wind_speed <= 38 and unit == "mph"
        ):
            return "Near Gale"
        if (62 >= wind_speed <= 74 and unit == "kmh") or (
            39 >= wind_speed <= 46 and unit == "mph"
        ):
            return "Gale"
        if (75 >= wind_speed <= 88 and unit == "kmh") or (
            47 >= wind_speed <= 54 and unit == "mph"
        ):
            return "Strong Gale"
        # Red
        if (89 >= wind_speed <= 102 and unit == "kmh") or (
            55 >= wind_speed <= 63 and unit == "mph"
        ):
            return "STORM"
        if (103 >= wind_speed <= 117 and unit == "kmh") or (
            64 >= wind_speed <= 72 and unit == "mph"
        ):
            return "VIOLENT STORM"
        if (wind_speed > 117 and unit == "kmh") or (wind_speed > 72 and unit == "mph"):
            return "HURRICANE"

        return ""

    def __get_weather_description(self, weather_code: int) -> str:
        r"""Simplified WMO Weather interpretation codes"""

        mapping: dict[int, str] = {
            0: "Clear",
            1: "Mainly Clear",
            2: "Partly Cloudy",
            3: "Overcast",
            45: "Foggy",
            48: "Rime Fog",
            51: "Drizzle",
            61: "Rain",
            71: "Snow",
            80: "Rain Showers",
            95: "Thunderstorm",
        }
        return mapping.get(weather_code, "Cloudy")

    def _get_precipitation_amount_level(
        self, amount: str, is_snow: bool = False
    ) -> int:
        """Assesses liquid precipitation sum (mm) from Open-Meteo.

        Args:
            amount: Raw precipitation_sum in mm (liquid water equivalent).
            is_snow: Pass True if temperature/weather code indicates snowfall.

        Returns:
            0: No precipitation
            1: Insignificant or low
            2: Medium
            3: Heavy
            50: Very heavy
            99: Disaster / Extreme
        """
        try:
            mm = float(amount)
        except (ValueError, TypeError):
            return 0

        if mm <= 0:
            return 0

        # Thresholds in liquid water equivalent (mm)
        # Snow accumulates at ~10x liquid depth, so liquid thresholds are 10x lower for snow
        low_thresh = 0.25 if is_snow else 2.5
        med_thresh = 1.0 if is_snow else 10.0
        heavy_thresh = 3.0 if is_snow else 50.0
        disaster_thresh = 10.0 if is_snow else 100.0  # ~100mm rain or ~100cm (1m) snow

        if mm < low_thresh:
            return 1
        elif mm < med_thresh:
            return 2
        elif mm <= heavy_thresh:
            return 3
        elif mm < disaster_thresh:
            return 50  # Very heavy / severe
        else:
            return 99  # Disaster level

    def get_precipitation_amount_assessment(
        self, amount: str, precip_type: str = "Rain"
    ) -> str:
        """Returns a human-readable assessment of the precipitation level.

        Args:
            amount: Raw precipitation_sum in mm.
            precip_type: String representing type of precipitation (e.g., 'Precip', 'Snow', 'Rain').
        """
        assessments: dict[int, str] = {
            0: "",
            1: "Low",
            2: "Moderate",
            3: "Heavy",
            50: "Extreme",
            99: "DISASTER",
        }

        # Safely evaluate whether the precipitation type string equals "SNOW"
        is_snow_bool = (
            isinstance(precip_type, str) and precip_type.strip().upper() == "SNOW"
        )

        level = self._get_precipitation_amount_level(amount, is_snow=is_snow_bool)
        return assessments.get(level, "")

    def __get_air_quality_assessment(self, aqi: int) -> str:
        r"""Returns a human-readable assessment based on the US EPA AQI scale."""

        assessments: dict[int, str] = {
            50: "Good",
            100: "Moderate",
            150: "Unhealthy for Sensitive Groups",
            200: "Unhealthy",
            300: "Very Unhealthy",
        }

        for aqilevel in sorted(assessments):
            if aqi <= aqilevel:
                return assessments[aqilevel]

        return "Hazardous"

    def __get_humidity_assessment(
        self, humidity: int, temperature: int, tunit: str
    ) -> str:
        r"""Returns a human-readable assessment based on the humidity and temperature."""

        # Converting a Fahrenheit into a Celsius
        if tunit.lower() == "f":
            temperature = (temperature - 32) * 5 / 9

        if humidity >= 70 and temperature > 30:
            return "HIGH"
        if humidity >= 80 and 27 <= temperature <= 31:
            return "Caution"
        if humidity >= 50 and 32 <= temperature <= 34:
            return "Extreme Caution"
        if humidity >= 40 and 35 <= temperature <= 37:
            return "DANGER"
        if humidity >= 35 and temperature > 38:
            return "EXTREME DANGER"

        if humidity < 15:
            return "LOW"
        if humidity < 30:
            return "Dry"
        if humidity <= 60:
            return "Comfortable"

        return "Humid"

    def __get_precipitation_type(self, weather_code: int) -> str:
        if 51 <= weather_code <= 67 or 80 <= weather_code <= 82:
            return "Rain"
        if 71 <= weather_code <= 77 or 85 <= weather_code <= 86:
            return "Snow"
        if 95 <= weather_code <= 99:
            return "Storm"
        return "Precip"

    def __is_daytime(
        self, lat: str | float, lon: str | float, dt: datetime | None = None
    ) -> bool:
        """
        Calculates if it is daytime at a specific coordinate and time
        without using external APIs.

        Code written with a Gemini formula.
        """
        if dt is None:
            dt = datetime.now(timezone.utc)

        lat = float(lat)
        lon = float(lon)

        zenith: float = 96.0
        day_of_year = dt.timetuple().tm_yday

        # Convert longitude to hour offset and calculate approximate sunrise/sunset
        # This is a simplified version of the General Solar Position Algorithm
        lng_hour = lon / 15.0

        # Calculate sunrise/sunset times
        t_rise = day_of_year + ((6 - lng_hour) / 24)
        t_set = day_of_year + ((18 - lng_hour) / 24)

        # Calculate Solar Mean Anomaly
        m_rise = (0.9856 * t_rise) - 3.289
        m_set = (0.9856 * t_set) - 3.289

        # Calculate True Longitude
        l_rise = (
            m_rise
            + (1.916 * math.sin(math.radians(m_rise)))
            + (0.020 * math.sin(math.radians(2 * m_rise)))
            + 282.634
        )
        l_set = (
            m_set
            + (1.916 * math.sin(math.radians(m_set)))
            + (0.020 * math.sin(math.radians(2 * m_set)))
            + 282.634
        )

        l_rise = l_rise % 360
        l_set = l_set % 360

        # Calculate Right Ascension
        ra_rise = (
            math.degrees(math.atan(0.91764 * math.tan(math.radians(l_rise)))) % 360
        )
        ra_set = math.degrees(math.atan(0.91764 * math.tan(math.radians(l_set)))) % 360

        # Adjust RA to be in the same quadrant as L
        l_quadrant = (math.floor(l_rise / 90)) * 90
        ra_quadrant = (math.floor(ra_rise / 90)) * 90
        ra_rise = ra_rise + (l_quadrant - ra_quadrant)

        l_quadrant = (math.floor(l_set / 90)) * 90
        ra_quadrant = (math.floor(ra_set / 90)) * 90
        ra_set = ra_set + (l_quadrant - ra_quadrant)

        # Convert RA to hours
        ra_rise /= 15
        ra_set /= 15

        # Calculate Declination
        sin_dec = 0.39782 * math.sin(math.radians(l_rise))
        cos_dec = math.cos(math.asin(sin_dec))

        # Calculate Local Hour Angle
        cos_h = (
            math.cos(math.radians(zenith)) - (sin_dec * math.sin(math.radians(lat)))
        ) / (cos_dec * math.cos(math.radians(lat)))

        # If cos_h > 1, sun never rises (Polar Night)
        # If cos_h < -1, sun never sets (Midnight Sun)
        if cos_h > 1:
            return False
        if cos_h < -1:
            return True

        h_rise = (360 - math.degrees(math.acos(cos_h))) / 15
        h_set = math.degrees(math.acos(cos_h)) / 15

        # Calculate Local Mean Time of rise/set
        t_rise = h_rise + ra_rise - (0.06571 * t_rise) - 6.622
        t_set = h_set + ra_set - (0.06571 * t_set) - 6.622

        # Convert to UTC
        utc_rise = (t_rise - lng_hour) % 24
        utc_set = (t_set - lng_hour) % 24

        # Current UTC time as a float
        current_utc_hour = dt.hour + dt.minute / 60.0 + dt.second / 3600.0

        # Handle the wrap-around for night (if sunset is before sunrise)
        if utc_rise < utc_set:
            return utc_rise <= current_utc_hour <= utc_set
        else:
            return current_utc_hour >= utc_rise or current_utc_hour <= utc_set

    def _get_main_location_weather_data(
        self, lat: str, lon: str, tunit: str, wunit: str, punit: str
    ) -> requests.Response:
        r"""Gets various weather data for the main location"""

        self.logger.info("** Querying Open-Meteo (home weather) **")

        base_url: str = "https://api.open-meteo.com/v1/forecast"
        url_params: dict[str, str | int] = {
            "latitude": lat,
            "longitude": lon,
            "current": "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,is_day,pressure_msl",
            "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,relative_humidity_2m_min,relative_humidity_2m_max",
            "timezone": "auto",
            "temperature_unit": tunit,
            "wind_speed_unit": wunit,
            "precipitation_unit": punit,
            "forecast_days": 8,
        }
        prepared: requests.PreparedRequest = requests.PreparedRequest()
        prepared.prepare_url(base_url, url_params)
        url: str = prepared.url
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(
                url, timeout=self.config.reqtimeout
            )
        except Exception:
            # Just making it more user-friendly
            raise Exception(
                f"Cannot get home weather data. Try again in a minute. Request timeout ({self.config.reqtimeout})."
            )

        self.logger.debug(f"RESPONSE={pf(response)}")

        if not response:
            raise Exception(
                f"Cannot obtain weather data. Error {response.status_code}: {APIIssues.get_api_problem(response.status_code)}"
            )

        return response.json()

    def _get_brief_weather_data(
        self, lats: str, lons: str, tunit: str
    ) -> requests.Response:
        r"""Gets only current temperatures, weather codes for a given location"""

        self.logger.info("** Querying Open-Meteo for the folowed cities **")
        if not self.config.followcities:
            self.logger.info("No cities of interest. Exiting.")
            return {}

        base_url: str = "https://api.open-meteo.com/v1/forecast"
        url_params: dict[str, str] = {
            "latitude": lats,
            "longitude": lons,
            "current": "temperature_2m,is_day,weather_code",
            "timezone": "auto",
            "temperature_unit": tunit,
        }
        prepared: requests.PreparedRequest = requests.PreparedRequest()
        prepared.prepare_url(base_url, url_params)
        url: str = prepared.url
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(
                url, timeout=self.config.reqtimeout
            )
        except Exception:
            # Just making it more user-friendly
            raise Exception(
                f"Cannot get weather data for the followed cities. Try again in a minute. Request timeout ({self.config.reqtimeout})."
            )

        self.logger.debug(f"RESPONSE={pf(response)}")

        if not response.ok or not response.text.strip():
            raise Exception(
                f"Cannot obtain weather data. Error {response.status_code}: {APIIssues.get_api_problem(response.status_code)}"
            )

        # self.logger.debug(f"STATUS={response.status_code} HEADERS={dict(response.headers)}")
        # self.logger.debug(f"BODY_LEN={len(response.text)} BODY={response.text[:200]!r}")
        return response.json()

    def _get_aqi_pollen_uv_data(self, lat: str, lon: str) -> requests.Response:
        r"""Gets only AQI data for the given location"""

        self.logger.info("** Querying Open-Meteo (AQI, Pollen, UV) **")

        params: list[str] = [
            "us_aqi",
            "alder_pollen",
            "birch_pollen",
            "olive_pollen",
            "grass_pollen",
            "mugwort_pollen",
            "ragweed_pollen",
            "uv_index",
        ]

        # Air Quality API (Separate endpoint but same coordinates)
        base_url: str = "https://air-quality-api.open-meteo.com/v1/air-quality"
        url_params: dict[str, str] = {
            "latitude": lat,
            "longitude": lon,
            "current": ",".join(params),
            "timezone": "auto",
        }
        prepared: requests.PreparedRequest = requests.PreparedRequest()
        prepared.prepare_url(base_url, url_params)
        url: str = prepared.url
        self.logger.debug(f"URL={url}")

        try:
            response: requests.Response = requests.get(
                url, timeout=self.config.reqtimeout
            )
        except Exception:
            # Just making it more user-friendly
            raise Exception(
                f"Cannot get air quality. Try again in a minute. Request timeout ({self.config.reqtimeout})."
            )

        self.logger.debug(f"RESPONSE={pf(response)}")

        if not response:
            raise Exception(
                f"Cannot obtain air quality data. Error {response.status_code}: {APIIssues.get_api_problem(response.status_code)}"
            )

        return response.json()

    def get_storm_warning(self, weather_code: int, wind: int) -> str:
        if 95 <= weather_code <= 99:
            if weather_code in [96, 99]:
                return "HAIL STORM WARNING"
            return "THUNDERSTORM WARNING"

        if 38 <= weather_code <= 39:
            return "BLIZZARD WARNING"

        # Wind Storms
        if wind > 60:
            return "HIGH WIND WARNING"

        return ""

    def get_baropressure_warning(self, baropressure: float) -> str:
        """Basic advisory"""

        if baropressure < 1000:
            return f"[{baropressure} hPa][RISK] Low atmospheric pressure. Risk for migraines, sinus headaches, arthritic joint pain, low blood pressure."
        if baropressure > 1026:
            return f"[{baropressure} hPa][RISK] High atmospheric pressure. Risk for high blood pressure, breathing issues."

        return ""

    def get_humidity_risk(self, humidity: int) -> str:
        """Returns a message only if humidity poses a risk for people with
        medical conditions (respiratory)"""

        if humidity < 30:
            return f"[{humidity}%][RISK] People with asthma or chronic resp. condt.: Airway irritation."

        if humidity < 40:
            return f"[{humidity}%][MOD.RISK] People with sensitive resp. systems: Mild airway irritation."

        if humidity <= 60:
            return ""  # Safe

        if humidity <= 70:
            return f"[{humidity}%][RISK] People with asthma: Increased allergens, difficult breathing."

        return f"[{humidity}%][HIGH RISK] People with resp. condt.: Labored breathing!"

    def get_pollen_and_uv_warnings(self, data: dict) -> list[str]:
        if not data:
            return []

        alder: float = data.get("alder_pollen") or 0.0
        birch: float = data.get("birch_pollen") or 0.0
        olive: float = data.get("olive_pollen") or 0.0
        tree_pollen_total: float = alder + birch + olive
        grass: float = data.get("grass_pollen") or 0.0
        mugwort: float = data.get("mugwort_pollen") or 0.0
        ragweed: float = data.get("ragweed_pollen") or 0.0
        weed_pollen_total: float = mugwort + ragweed
        uv_index: float = data.get("uv_index") or 0.0

        warnings: list[str] = []

        if tree_pollen_total >= 250:
            warnings.append(
                f"[VERY HIGH RISK] Extremely high tree pollen: {tree_pollen_total:.1f} grains/m³."
            )
        elif tree_pollen_total >= 70:
            warnings.append(
                f"[HIGH RISK] High tree pollen levels: {tree_pollen_total:.1f} grains/m³."
            )
        elif tree_pollen_total >= 10:
            warnings.append(
                f"[MODERATE RISK] Moderate tree pollen levels: {tree_pollen_total:.1f} grains/m³."
            )

        if grass >= 150:
            warnings.append(
                f"[VERY HIGH RISK][HAY FEVER/RESPIRATORY] Very high grass pollen: {grass:.1f} grains/m³."
            )
        elif grass >= 50:
            warnings.append(
                f"[HIGH RISK][HAY FEVER/RESPIRATORY] High grass pollen levels: {grass:.1f} grains/m³."
            )
        elif grass >= 20:
            warnings.append(
                f"[MODERATE RISK][HAY FEVER/RESPIRATORY] Grass pollen levels: {grass:.1f} grains/m³."
            )

        if weed_pollen_total >= 80:
            warnings.append(
                f"[VERY HIGH RISK] Extremely high weed pollen: {weed_pollen_total:.1f} grains/m³."
            )
        elif weed_pollen_total >= 30:
            warnings.append(
                f"[HIGH RISK] High weed pollen levels: {weed_pollen_total:.1f} grains/m³."
            )
        elif weed_pollen_total >= 10:
            warnings.append(
                f"[MODERATE RISK] Moderate weed pollen levels: {weed_pollen_total:.1f} grains/m³."
            )

        if 6.0 <= uv_index < 8.0:
            warnings.append(
                f"[HIGH RISK][SUN ALLERGY] UV Index: {uv_index:.1f}. Limit direct sun exposure! Avoid being out 11:00-16:00!"
            )
        elif 8.0 <= uv_index < 11.0:
            warnings.append(
                f"[HIGH RISK][SUN ALLERGY] UV Index: {uv_index:.1f}. Extra protection needed!"
            )
        elif uv_index >= 11.0:
            warnings.append(
                f"[EXTREME RISK][SUN ALLERGY] UV Index: {uv_index:.1f}. Avoid exposure!"
            )

        return warnings

    def _sort_cities(self, cities: list[dict], sort_key: str) -> list[dict]:
        """Ascending sort either by city or country and city."""

        mode = sort_key.lower()
        home_country_code2 = self.config.country_code2

        if mode == "city":
            # Home-country cities first (sorted by city name), then the rest
            # sorted strictly by the "city" field
            return sorted(
                cities,
                key=lambda x: (
                    x["country_code2"] != home_country_code2,
                    x["city"],
                ),
            )

        if mode == "country":
            # Home-country cities first (sorted by city name), then the rest
            # sorted by "country_code2" first, then by "city"
            return sorted(
                cities,
                key=lambda x: (
                    x["country_code2"] != home_country_code2,
                    x["country_code2"],
                    x["city"],
                ),
            )

        # Unsorted
        return list(cities)

    def get_data(self) -> None:
        # Build the API URL with your config preferences
        tunit = "celsius" if self.config.celsius else "fahrenheit"
        wunit: str = "mph"
        punit: str = "in"

        if self.config.metric:
            wunit = "kmh"
            punit = "mm"

        # Format Date and Time
        now: datetime = datetime.now(ZoneInfo(self.config.timezone))

        self.cache.register("weather_data", self.data)
        if self.cache.too_soon and not self.cache.loaded:
            self.cache.load()

            # Re-sorting the cached data
            cities_data_raw = getattr(self.data, "cities_data_raw", None)
            if cities_data_raw:
                self.data.cities_data = self._sort_cities(
                    cities_data_raw, self.config.cities_sorting
                )
            return

        warnings: WarningsManager = WarningsManager()
        warnings.home_location = f"{self.config.city}-{self.config.country_code2}"

        # bulgarian: Bulgarian = Bulgarian()
        astronomer: Astronomer = Astronomer()
        holidays_manager: HolidaysManager = HolidaysManager()
        spaceweather_advisor: SpaceWeatherAdvisor = SpaceWeatherAdvisor(
            self.config.reqtimeout
        )
        electrostatic_advisor: ElectrostaticAdvisor = ElectrostaticAdvisor()
        disaster_advisor: DisasterAdvisor = DisasterAdvisor(
            reqtimeout=self.config.reqtimeout,
        )

        # Ok, let's be a bit more nicer
        # additional_fact_country: str = ""
        # if self.config.country != "Bulgaria":
        #     additional_fact_country = self.config.country

        with concurrent.futures.ThreadPoolExecutor(max_workers=15) as executor:

            # Send the requests
            # -----------------
            future_weather = executor.submit(
                self._get_main_location_weather_data,
                self.config.lat,
                self.config.lon,
                tunit,
                wunit,
                punit,
            )
            future_aqi_pollen_uv = executor.submit(
                self._get_aqi_pollen_uv_data, self.config.lat, self.config.lon
            )
            if self.config.followcities:
                future_followcities = executor.submit(
                    self._get_brief_weather_data,
                    ",".join([e["lat"] for e in self.config.followcities]),
                    ",".join([e["lon"] for e in self.config.followcities]),
                    tunit,
                )
            # future_motivation = executor.submit(
            #     Motivator.get_motivation, self.config.reqtimeout
            # )
            # future_fact = executor.submit(
            #     bulgarian.get_history_fact,
            #     self.config.reqtimeout,
            #     additional_fact_country,
            # )
            future_sun_times = executor.submit(
                astronomer.get_sun_times,
                self.config.lat,
                self.config.lon,
                self.config.timezone,
                self.config.reqtimeout,
            )
            future_holidays = executor.submit(
                holidays_manager.update,
                self.config.holidays,
                self.config.country_code2,
                self.config.reqtimeout,
            )
            future_electrostatic_xray_flux = executor.submit(
                electrostatic_advisor.get_xray_flux,
                self.config.reqtimeout,
            )
            future_disasters = executor.submit(
                disaster_advisor.get_disasters, self.config.countries_of_interest
            )
            future_wildfires = executor.submit(
                disaster_advisor.get_detailed_fires,
                self.config.lat,
                self.config.lon,
                self.config.timezone,
            )
            future_quakes = executor.submit(
                disaster_advisor.get_detailed_quakes,
                self.config.lat,
                self.config.lon,
                self.config.continent_code,
                self.config.timezone,
            )
            future_fireballs = executor.submit(disaster_advisor.get_fireballs)
            future_geomagnetic_scales = executor.submit(
                spaceweather_advisor.get_geomagnetic_scales
            )
            future_kp_index = executor.submit(spaceweather_advisor.get_kp_index)

            # Get the responses
            # -----------------
            weather_result: dict = future_weather.result()
            aqi_pollen_uv_result: dict = future_aqi_pollen_uv.result()
            if self.config.followcities:
                followcities_result: dict | list = future_followcities.result()
            fireballs: list[list[str]] = future_fireballs.result()
            quakes: list[list[str]] = future_quakes.result()
            wildfires: list[list[str]] = future_wildfires.result()
            geomagnetic_scales: dict[str, any] = future_geomagnetic_scales.result()
            kp_index: float = future_kp_index.result()
            spaceweather_warnings: list[list[str]] = spaceweather_advisor.get_warnings(
                kp_index, geomagnetic_scales
            )
            # motivation: list[str] = future_motivation.result()
            disasters: list[list[str]] = future_disasters.result()
            electrostatic_xray_flux: str = future_electrostatic_xray_flux.result()
            electrostatic_warning: str = (
                electrostatic_advisor.get_electrostatic_warning(electrostatic_xray_flux)
            )

            new_holidays: dict[str, str] = future_holidays.result()
            if self.config.holidays != new_holidays:
                self.config.holidays = new_holidays
                self.config.save()

            # history_fact: str = future_fact.result()

            celestial: dict[str, any] = {
                "sun": future_sun_times.result(),  # dict[str, str]
                "zodiac": astronomer.get_zodiac_sign(),  # str
                "chinese": astronomer.get_chinese_zodiac(),  # str
                "moon": astronomer.get_moon_phase(),  # str
            }

            # if history_fact == "":  # More motivation. Because why not?
            #     history_fact = f"'{motivation[0]}' //{motivation[1]}"
            # fact_paragraph: TextParagraph = TextParagraph(history_fact)

            # history_fact: dict[str, date | TextParagraph] = {
            #     "date": date.today(),
            #     "text": fact_paragraph,
            # }

            # Apply data
            # ----------

            # Extract Data
            current: dict[str, str] = weather_result["current"]
            daily: dict[str, str] = weather_result["daily"]
            home_timezone_name: str = weather_result["timezone"]
            # Air quality, pollen, UV
            aqi_pollen_uv_result_data = aqi_pollen_uv_result.get("current", {})
            aqi: str = aqi_pollen_uv_result_data["us_aqi"] or 0

            # Okay let's get this straight - so we're getting the timezone name
            # after ip-location discovery, but still just in case this does
            # not hurt to be here as an insurance
            if self.config.timezone == "":
                self.config.timezone = home_timezone_name
                self.config.save()

            # Fill the object with data
            # self.data.is_day = True if current["is_day"] == "1" else False
            self.data.sky = self.__get_weather_description(current["weather_code"])
            self.data.temperature = round(float(current["temperature_2m"]))
            self.data.min = round(float(daily["temperature_2m_min"][0]))
            self.data.max = round(float(daily["temperature_2m_max"][0]))
            self.data.hmin = round(float(daily["relative_humidity_2m_min"][0]))
            self.data.hmax = round(float(daily["relative_humidity_2m_max"][0]))
            self.data.hcur = round(float(current["relative_humidity_2m"]))
            self.data.baropressure = round(float(current["pressure_msl"]))
            self.data.aqi = int(aqi)
            self.data.air_quality = self.__get_air_quality_assessment(aqi)
            self.data.precipitation = daily["precipitation_probability_max"][0]
            self.data.precipitation_sum = daily["precipitation_sum"][0]
            self.data.weather_code = current["weather_code"]
            self.data.wind = int(current["wind_speed_10m"])
            self.data.wind_direction = self.__get_wind_direction(
                current["wind_direction_10m"]
            )

            self.data.week = []

            # followcities_result[i]["current"]["temperature_2m"]
            # followcities_result[i]["current"]["is_day"]
            modification_was_done: bool = False
            if self.config.followcities:
                self.data.cities_data = []
                if isinstance(followcities_result, dict):
                    city_data: dict = {
                        "temperature": round(
                            float(followcities_result["current"]["temperature_2m"])
                        ),
                        "is_day": followcities_result["current"]["is_day"],
                        "city": self.config.followcities[-1]["city"],
                        "country": self.config.followcities[-1]["country"],
                        "country_code2": self.config.followcities[-1]["country_code2"],
                        "province": self.config.followcities[-1]["province"],
                        "timezone": self.config.followcities[-1]["timezone"],
                        "weather_code": followcities_result["current"]["weather_code"],
                        "sky": self.__get_weather_description(
                            followcities_result["current"]["weather_code"]
                        ),
                    }

                    if city_data["timezone"] == "":
                        city_data["timezone"] = followcities_result["timezone"]
                        self.config.followcities[-1]["timezone"] = followcities_result[
                            "timezone"
                        ]
                        modification_was_done = True

                    self.data.cities_data = [city_data]
                if isinstance(followcities_result, list):
                    # followcities_result = followcities_result
                    for i, combodata in enumerate(
                        zip(self.config.followcities, followcities_result)
                    ):
                        city_data: dict = {
                            "temperature": round(
                                float(combodata[1]["current"]["temperature_2m"])
                            ),
                            "is_day": combodata[1]["current"]["is_day"],
                            "city": combodata[0]["city"],
                            "country": combodata[0]["country"],
                            "country_code2": combodata[0]["country_code2"],
                            "province": combodata[0]["province"],
                            "timezone": combodata[0]["timezone"],
                            "weather_code": combodata[1]["current"]["weather_code"],
                            "sky": self.__get_weather_description(
                                combodata[1]["current"]["weather_code"]
                            ),
                        }

                        if city_data["timezone"] == "":
                            city_data["timezone"] = followcities_result[i]["timezone"]
                            self.config.followcities[i]["timezone"] = (
                                followcities_result[i]["timezone"]
                            )
                            modification_was_done = True

                        self.data.cities_data.append(city_data)

                # Did we filled any missing config info? Timezone names maybe?
                if modification_was_done:
                    self.config.save()

                # Keep an untouched copy of the build order so that a
                # cached payload can still be re-sorted correctly later
                # (see the cache-hit branch above), including "unsorted".
                self.data.cities_data_raw = list(self.data.cities_data)

                # Sorting the cities for output
                self.data.cities_data = self._sort_cities(
                    self.data.cities_data, self.config.cities_sorting
                )

            # 7 day forecast
            for i in range(1, 8):
                day = BriefDailyForecast()
                day.min = round(float(daily["temperature_2m_min"][i]))
                day.max = round(float(daily["temperature_2m_max"][i]))
                day.precip = round(float(daily["precipitation_probability_max"][i]))
                day.precip_sum = daily["precipitation_sum"][i]
                day.dow = (now + timedelta(days=i)).strftime("%a")
                self.data.week.append(day)

            self.data.wind_type = self.__get_wind_type(self.data.wind, wunit)
            self.data.precipitation_type = self.__get_precipitation_type(
                self.data.weather_code
            )  # Rain, Snow, Storm, Precip
            self.data.humidity_level_min = self.__get_humidity_assessment(
                self.data.hmin, self.data.min, tunit
            )
            self.data.humidity_level_max = self.__get_humidity_assessment(
                self.data.hmax, self.data.max, tunit
            )
            self.data.humidity = self.__get_humidity_assessment(
                self.data.hcur, self.data.temperature, tunit
            )
            self.data.wind_direction_long = self.__get_wind_direction_long(
                self.data.wind_direction
            )
            self.data.is_day = self.__is_daytime(self.config.lat, self.config.lon)

            now: datetime = datetime.now()
            date_str: str = now.strftime("%Y-%m-%d")
            stuff: str = pick_one(facts_sheet)
            stuff = f"FACT: {stuff}"
            if date_str in self.config.holidays:
                stuff = self.config.holidays[date_str].split("#")[1]
                stuff = f"NATIONAL HOLIDAY: {stuff}"

            # Misc
            # ----
            # self.data.misc_data["motivation"] = motivation
            # self.data.misc_data["histfact"] = history_fact
            self.data.misc_data["celestial"] = celestial
            self.data.misc_data["misc"] = stuff
            self.data.misc_data["seasonals"] = LocalProduceAdvisor.get_seasonal_produce(
                lat=float(self.config.lat),
                lon=float(self.config.lon),
                month=datetime.now().month,
            )
            self.data.misc_data["health_motivation"] = pick_one(HEALTH_MOTIVATION)

            humidity_risk: str = self.get_humidity_risk(self.data.hcur)
            storm_warning: str = self.get_storm_warning(
                self.data.weather_code, self.data.wind
            )
            baropressure_warning: str = self.get_baropressure_warning(
                self.data.baropressure
            )
            pollen_and_uv_warnings: list[str] = self.get_pollen_and_uv_warnings(
                aqi_pollen_uv_result_data
            )

            # WARNINGS
            # --------
            if humidity_risk:
                warnings.append("home", "", "HUMIDITY", humidity_risk)
            if storm_warning:
                warnings.append("home", "", "STORM", storm_warning)
            if baropressure_warning:
                warnings.append("home", "", "BAROPRESSURE", baropressure_warning)
            for fireball in fireballs:
                warnings.append(*fireball)
            for quake in quakes:
                warnings.append(*quake)
            for wildfire in wildfires:
                warnings.append(*wildfire)
            for space_warning in spaceweather_warnings:
                warnings.append(*space_warning)
            for disaster in disasters:
                warnings.append(*disaster)
            if electrostatic_warning:
                warnings.append("home", "", "ELECTROSTATIC", electrostatic_warning)
            for message in pollen_and_uv_warnings:
                warnings.append("home", "", "ALLERGY", message)

            self.cache.save()


class PresentationConfiguration:
    r"""Common presentation logic"""

    def __init__(self, config: Configuration) -> None:
        self.locale_id = f"{config.language}_{config.country_code2}"

        # Units
        self.tsuffix: str = "C" if config.celsius else "F"
        self.tunit: str = "celsius" if config.celsius else "fahrenheit"
        self.wunit: str = "mph"
        self.punit: str = "in"

        if config.metric:
            self.wunit = "kmh"
            self.punit = "mm"

        # Removing provinces which contain only numbers
        province_has_letters = bool(re.search(r"\D", config.province))
        self.province: str = ""
        if province_has_letters:
            self.province = f"{config.province}, "

        self.time24: bool = config.time24
        self.timezone: str = config.timezone
        self.date_format_length: str = config.date_format_length
        self.continent_code: str = config.continent_code

        self.date: str = ""
        self.time: str = ""
        self.dow: str = ""
        self.day_of_week: str = ""
        self.season: str = ""

    def get_time_for_timezone(self, timezone_str: str) -> str:
        try:
            target_zone: ZoneInfo = ZoneInfo(timezone_str)
            city_time: datetime = datetime.now(target_zone)
            fmt: str = "%H:%M" if self.time24 else "%I:%M%p"
            return city_time.strftime(fmt)
        except ZoneInfoNotFoundError:
            return "00:00"

    def get_season(self, continent_code: str) -> str:
        now: datetime = datetime.now()
        md: int = now.month * 100 + now.day  # May 2nd is 502
        season: str = "Winter"

        if 320 <= md < 621:
            season = "Spring"
        if 621 <= md < 922:
            season = "Summer"
        if 922 <= md < 1221:
            season = "Fall"

        if continent_code in ["SA", "OC", "AN", "AF"]:
            # Logic to flip seasons for the South
            flip = {
                "Spring": "Fall",
                "Summer": "Winter",
                "Fall": "Spring",
                "Winter": "Summer",
            }
            return flip[season]

        return season

    def update_time(self) -> str:
        now: datetime = datetime.now(ZoneInfo(self.timezone))
        try:
            self.date = format_date(
                now, format=self.date_format_length, locale=self.locale_id
            )
        except Exception:
            self.date = format_date(
                now, format=self.date_format_length, locale=DEFAULT_LOCALE
            )
        # self.dow = format_date(now, format="cccccc", locale=self.locale_id).title()
        self.dow = format_date(now, format="EEE", locale=DEFAULT_LOCALE).title()
        self.day_of_week = format_date(now, format="EEEE", locale=DEFAULT_LOCALE).title()
        self.season = self.get_season(self.continent_code)

        # Time 12/24
        time_pattern = "%H:%M" if self.time24 else "%I:%M %p"
        self.time = now.strftime(time_pattern)

        return self.time

    def abbreviate_name(self, name: str) -> str:
        """Abbreviates common geographic names."""

        if not name:
            return ""

        abbreviations: dict[str, str] = {
            "saint": "St.",
            "sainte": "Ste.",
            "fort": "Ft.",
            "mount": "Mt.",
            "mountain": "Mtn.",
            "point": "Pt.",
            "pointe": "Pte.",
            "port": "Pt.",
            "heights": "Hts.",
            "springs": "Spgs.",
            "junction": "Jct.",
            "township": "Twp.",
            "north": "N.",
            "south": "S.",
            "east": "E.",
            "west": "W.",
            "northeast": "NE.",
            "northwest": "NW.",
            "southeast": "SE.",
            "southwest": "SW.",
            "republic": "Rep.",
            "republique": "Rep.",
            "republik": "Rep.",
            "department": "Dept.",
            "county": "Cty.",
        }

        temp_name: str = name.replace("-", " - ")
        words: list[str] = temp_name.split()
        abbreviated_words: list[str] = []

        for word in words:
            clean_word: str = word.strip(",.").lower()

            if clean_word in abbreviations:
                abbr: str = abbreviations[clean_word]
                if word.endswith(","):
                    abbr += ","
                abbreviated_words.append(abbr)
            else:
                abbreviated_words.append(word)

        result: str = " ".join(abbreviated_words)
        result = result.replace(" - ", "-")
        result = result.replace(".-", ".")

        return result


class Views:
    r"""Definition of views"""

    def __init__(
        self,
        config: Configuration,
        present_config: PresentationConfiguration,
    ) -> None:
        self.config: Configuration = config
        self.warnings: WarningsManager = WarningsManager()
        self.warnings.home_location = f"{self.config.city}-{self.config.country_code2}"
        self.presconf: PresentationConfiguration = present_config
        self.weather_refresh_interval: int = REFRESH_INTERVAL
        self.weather_refresh_interval_on_fail: int = REFRESH_INTERVAL_ON_FAIL
        self.height: int | None = None
        self.width: int | None = None
        self.forecaster: WeatherForecaster = WeatherForecaster(self.config)

        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

    def prog_bar(
        self, percent: int, fillchar: str = "#", emptychar: str = ".", maxchar: int = 10
    ) -> str:
        """Returns a simple progress bar as a string."""
        # DOS Era characters:
        # █ (Full Block) or ▓ (Dark Shade) for progress
        # ░ (Light Shade) for the background/remaining

        # Ensure percent stays within 0-100 bounds
        percent = max(0, min(100, percent))

        # Convert percent (0-100) into a proportional character count (0-maxchar)
        count = round(percent / 100 * maxchar)
        count = max(0, min(maxchar, count))  # safety clamp

        bar = (fillchar * count) + (emptychar * (maxchar - count))
        return bar

    def test_terminal_resized(
        self, stdscr: curses.window, terminal_size: TerminalSize
    ) -> bool:
        """Test if terminal was resized. Also if terminal is the minimum size required."""

        rows, columns = stdscr.getmaxyx()
        if rows < terminal_size.rows or columns < terminal_size.columns:
            raise Exception(
                f"The view you're trying to use requires a larger terminal. Current terminal: {columns}x{rows}; Required: {terminal_size.columns}x{terminal_size.rows}. Try another view. Run with --help"
            )
        if (columns, rows) != (self.width, self.height):
            self.width = columns
            self.height = rows
            return True

        return False

    def screen(self, stdscr: curses.window) -> None:
        r"""The actual TUI screen to display"""
        raise NotImplementedError

    def display(self) -> None:
        r"""Wrapper for screen(), unless simple printing"""
        raise NotImplementedError

    def update_screen(self) -> None:
        r"""Pushes all screen updates at once, if applicable"""
        raise NotImplementedError


class ColorViews(Views):
    r"""Definition of color views"""

    def _get_warnings_cp(self, homeremote: str, label: str, message: str) -> str:
        r"""Gets the color pair for warning messages"""

        # Idea is to trigger different color on "home" and "remote"
        # the rest are just meh
        if homeremote.lower() not in [
            "home",
            "remote",
            "emergency",
            "disaster",
            "",
            "***",
            "meh",  # Nope. Don't ask.
            "error",
        ]:
            raise ValueError(f"Invalid value '{homeremote}' for homeremote.")

        homeremotes: dict[str, int | str] = {
            "home": "normal",
            "remote": "general",
            "emergency": "emergency",
            "disaster": "emergency",
        }
        labels: dict[str, int | str] = {
            "fire": "emergency",
            "wildfire": "emergency",
            "flood": "water",
            "tsunami": "water",
            "earthquake": "normal",
            "radioblackout": "general",
            "emergency": "emergency",
            "disaster": "emergency",
            "humidity": "general",
            "amber": "emergency",
            "allergy": "general",
            "uv": "important",
            "xray": "normal",
            "geomagnetic": "general",
            "electrostatic": "general",
            "solarradiation": "general",
            "fireball": "general",
            "space": "general",
            "air": "important",
            "baropressure": "normal",
            "storm": "emergency",
            "volcano": "emergency",
            "drought": "normal",
        }

        # List of labels which does not apply for the majority of population
        generics: list[str] = [
            "remote",
            "humidity",
            "allergy",
            "geomagnetic",
            "electrostatic",
            "space",
            "radioblackout",
            "solarradiation",
            "fireball",
        ]

        # First priority
        if homeremote.lower() == "error":
            return "important"
        # Severities
        if homeremote.lower() == "home" and "extreme risk" in message.lower():
            return "emergency"
        if (
            homeremote.lower() == "home"
            and label.lower() in generics
            and "high risk" in message.lower()
        ):
            return "normal"

        # if homeremote.lower() in ["home", "disaster"] and label.lower() in labels:
        if homeremote.lower() == "home" and label.lower() in labels:
            return labels[label.lower()]

        if homeremote.lower() in homeremotes:
            return homeremotes[homeremote.lower()]

        return "regular"

    def _get_humidity_cp(self, humidity: int, temperature: int, tunit: str) -> str:
        r"""Get the humidity color pair"""

        # Converting a Fahrenheit into Celsius
        if tunit.lower() == "f":
            temperature = (temperature - 32) * 5 / 9

        if humidity >= 70 and temperature > 30:
            return "important"
        if humidity >= 80 and 27 <= temperature <= 31:
            return "important"
        if humidity >= 50 and 32 <= temperature <= 34:
            return "important"
        if humidity >= 40 and 35 <= temperature <= 37:
            return "important"
        if humidity >= 35 and temperature > 38:
            return "emergency"

        if humidity < 15:
            return "emergency"
        if humidity < 30:
            return "important"
        if humidity <= 60:
            return "good"

        return "water"

    def _get_precipitation_amount_cp(self, level: str) -> str:
        levels: dict[str, int | str] = {
            "": "regular",
            "LOW": "regular",
            "MODERATE": "water",
            "HEAVY": "important",
            "EXTREME": "error",
            "DISASTER": "emergency",
        }
        return levels.get(level.upper(), "regular")

    def _get_daynight_cp(self, is_day: bool) -> str:
        r"""Get the day or night color pair"""
        if is_day:
            return "normal"
        return "regular"

    def _get_daynight_icon_cp(self, is_day: bool) -> str:
        r"""Get the day or night color pair"""
        if is_day:
            return "general"
        return "regular"

    def _get_temp_cp(self, t: int, tunit: str) -> str:
        r"""Get the appropriate temperature color pair"""

        # Converting a Fahrenheit into Celsius
        if tunit.lower() == "f":
            t = (t - 32) * 5 / 9

        if t < -40:
            return "emergency"
        if -40 <= t < -30:
            return "error"
        if -30 <= t < -20:
            return "important"
        if -20 <= t < 11:
            return "water"
        if 11 <= t <= 20:
            return "good"
        if 21 <= t <= 38:
            return "important"
        if 39 <= t <= 44:
            return "error"
        return "emergency"

    def _get_city_cp(self, city: str, country_code2: str) -> str:
        if " STN" in city:  # Polar stations
            return "water"
        if country_code2.lower() == "xx":  # Random Earth points
            return "home"  # White
        return "general"

    def _get_wind_cp(self, wind_speed: int, unit: str) -> str:
        r"""Beaufort Scale"""

        scale: dict[str, dict[int, int]] = {
            "kmh": {50: "normal", 89: "important", 117: "error"},
            "mph": {32: "normal", 55: "important", 72: "error"},
        }

        for treshold in scale[unit].keys():
            if wind_speed < treshold:
                return scale[unit][treshold]

        return "emergency"  # Extreme (Hurricane)

    def _get_sky_cp(self, sky: str) -> str:
        r"""Get the appropriate sky color pair"""

        d: dict[str, int] = {
            # -------------------- nice
            "Clear": "important",
            "Mainly Clear": "important",
            # -------------------- clouds
            "Partly Cloudy": "normal",
            "Overcast": "normal",
            "Foggy": "normal",
            "Rime Fog": "normal",
            "Cloudy": "normal",
            # -------------------- rains
            "Drizzle": "water",
            "Rain": "water",
            "Snow": "normal",
            "Rain Showers": "water",
            "Thunderstorm": "error",
        }
        if sky not in d:
            raise ValueError(f"Invalid sky value '{sky}'.")
        return d[sky]

    def _get_precipitation_type_cp(self, precip_type: str) -> str:
        r"""Get the appropriate precipitation type color pair"""

        d: dict[str, int] = {
            "Rain": "water",
            "Snow": "normal",
            "Storm": "emergency",
        }
        if precip_type in d:
            return d[precip_type]

        return None  # COL_WHITEBLACK

    def _get_aqistr_cp(self, aqistr: str) -> str:
        r"""Get the appropriate aqi color pair"""

        d: dict[str, int] = {
            # -------------------- green
            "Good": "good",
            # -------------------- yellow
            "Moderate": "important",
            "Unhealthy for Sensitive Groups": "important",
            # -------------------- red
            "Unhealthy": "error",
            "Very Unhealthy": "error",
            "Hazardous": "error",
        }
        if aqistr not in d:
            raise ValueError(f"Invalid AQI value '{aqistr}'.")
        return d[aqistr]

    def _get_progbar_cp(self, p: int) -> str:
        if p < 40:
            return "regular"
        return "water"

    def update_screen(self) -> None:
        r"""Pushes all changes to screen at once"""
        curses.doupdate()

    def test_exit_conditions(self, input: int) -> bool:
        if input == ord("q") or input == ord("Q"):
            return True

        # The terminal window was closed; exit immediately
        if not os.isatty(sys.stdin.fileno()):
            # sys.exit(0)
            raise Exception("Not a TTY.")

        if not hasattr(self, "consecutive_errors"):
            self.consecutive_errors: int = 0
            self.last_error_time: float = time.time()

        if input == curses.ERR:
            current_time: float = time.time()
            elapsed: float = current_time - self.last_error_time
            self.consecutive_errors += 1

            # If we got 20 errors in less than 1 second, it's a 100% CPU ghost loop
            if self.consecutive_errors > 20 and elapsed < 1.0:
                raise Exception(f"Ghost loop detected: 20 errors in {elapsed:.2f}s")

            # If it's been more than a second, it's likely just a timeout,
            # so we reset the timer and counter to start fresh.
            if elapsed >= 1.0:
                self.consecutive_errors = 0
                self.last_error_time = current_time

            return False
        else:
            self.consecutive_errors = 0
            return False

    def _get_daynight_icon(self, is_day: bool) -> str:
        if is_day:
            return "🔅"
        return "🌑"

    def _get_weather_description_icon(self, weather_code: int, is_day: bool) -> str:
        r"""Weather icons"""

        mapping: dict[bool, dict[int, str]] = {
            True: {
                0: "☀️",  # Clear
                1: "🌤️",  # Mainly clear
                2: "⛅",  # Partly cloudy
                3: "☁️",  # Overcast
                45: "🌫️",  # Foggy
                48: "🌫️",  # Rime fog
                51: "🌦️",  # Drizzle
                61: "🌧️",  # Rain
                71: "❄️",  # Snow
                80: "🌦️",  # Rain showers
                95: "🌩️",  # Thunderstorm
            },
            False: {
                0: "☾",  # Clear Night (Crescent Moon)
                1: "☾",  # Mainly Clear (or 🌃 Night with Stars)
                2: "☁️",  # Partly Cloudy (or 🌙 + ☁️)
                3: "☁️",  # Overcast
                45: "🌫️",  # Foggy
                48: "🌫️",  # Rime fog
                51: "🌦️",  # Drizzle
                61: "🌧️",  # Rain
                71: "❄️",  # Snow
                80: "🌧️",  # Rain showers
                95: "🌩️",  # Thunderstorm
            },
        }
        return mapping[is_day].get(weather_code, "☁️")  # Cloudy

    def _get_moon_icon(self, phase: str) -> str:
        """Moon phase icons"""

        mapping: dict[str, str] = {
            "New Moon": "🌑",
            "Waxing Crescent": "🌒",
            "First Quarter": "🌓",
            "Waxing Gibbous": "🌔",
            "Full Moon": "🌕",
            "Waning Gibbous": "🌖",
            "Last Quarter": "🌗",
            "Waning Crescent": "🌘",
        }
        return mapping.get(phase, "☁️")  # New Moon

    def _get_zodiac_icon(self, sign: str) -> str:
        """Zodiac icons"""

        mapping: dict[str, str] = {
            "Aries": "🐏",
            "Taurus": "🐂",
            "Gemini": "👯",
            "Cancer": "🦀",
            "Leo": "🦁",
            "Virgo": "🌾",
            "Libra": "⚖️",
            "Scorpio": "🦂",
            "Sagittarius": "🏹",
            "Capricorn": "🐐",
            "Aquarius": "🏺",
            "Pisces": "🐟",
        }
        return mapping[sign]

    def _get_chinese_icon(self, sign: str) -> str:
        """Chinese zodiac icons"""

        mapping: dict[str, str] = {
            "Rat": "🐀",
            "Ox": "🐂",
            "Tiger": "🐅",
            "Rabbit": "🐇",
            "Dragon": "🐉",
            "Snake": "🐍",
            "Horse": "🐎",
            "Goat": "🐐",
            "Monkey": "🐒",
            "Rooster": "🐓",
            "Dog": "🐕",
            "Pig": "🐖",
        }
        return mapping[sign]


class SetupView(Views):
    r"""Prints the setup"""

    def display(self) -> None:
        self.logger.info("View is running")

        # Data to display
        city: str = self.config.city
        province: str = self.presconf.province
        country: str = self.config.country
        followed_cities: list[dict[str | int]] = self.config.followcities

        print(f"""==================================================[ SETUP ]
HOME CITY         |  {city}, {province}{country}""")

        print("ADDITIONAL CITIES |  ", end="")
        if len(followed_cities) == 0:
            print("None")
        else:
            for i, c in enumerate(followed_cities):
                (
                    print(f"{i+1}) {c['city']}, {c['country']}")
                    if i == 0
                    else print(
                        f"                  |  {i+1}) {c['city']}, {c['country']}"
                    )
                )

        print("")


class SeasonalView(Views):
    r"""Just prints"""

    def display(self) -> None:
        self.logger.info("View is running")

        abbr: dict[str, str] = {
            "spring": "SPR",
            "summer": "SUM",
            "fall": "FAL",
            "winter": "WIN",
        }

        # Data to display
        city: str = self.config.city
        province: str = self.presconf.province
        country: str = self.config.country
        follow_cities: list = self.config.followcities

        presconf: PresentationConfiguration = PresentationConfiguration(self.config)
        season: str = presconf.get_season(self.config.continent_code)

        seasonals: dict[str, list[str]] = LocalProduceAdvisor.get_seasonal_produce(
            lat=float(self.config.lat),
            lon=float(self.config.lon),
            month=datetime.now().month,
        )
        veggies: str = ""
        fruits: str = ""

        if seasonals:
            veggies = ",".join(seasonals["veggies"])
            fruits = ",".join(seasonals["fruits"])

        hr: str = "==============================="
        naz: str = "    *** Non-Agricultural Zone location ***"

        print("\nSEASONAL FRUITS AND VEGETABLES")
        print(hr)
        print(f"HOME: {city}, {province}{country}")
        # print(f"    Season     | {season}")
        if seasonals:
            print(f"    {abbr[season.lower()]}.Fruits     | {fruits}")
            print(f"    {abbr[season.lower()]}.Vegetables | {veggies}")
        else:
            print(naz)

        print("\nCITIES OF INTEREST")
        print(hr)

        for city_cnt, city_data in enumerate(follow_cities):
            city2: str = city_data["city"]
            province2: str = city_data["province"]
            country2: str = city_data["country"]
            seasonals = LocalProduceAdvisor.get_seasonal_produce(
                lat=float(city_data["lat"]),
                lon=float(city_data["lon"]),
                month=datetime.now().month,
            )
            if seasonals:
                veggies = ",".join(seasonals["veggies"])
                fruits = ",".join(seasonals["fruits"])

            if province2.isdigit():
                province2 = ""
            elif len(province2) > 0:
                province2 = f", {province2}"
            season = presconf.get_season(city_data["continent_code"])
            s: str = f"{city_cnt+1:>2}. {city2}{province2}, {country2}"

            print(s)
            # print(f"    Season     | {season}")
            if seasonals:
                print(f"    {abbr[season.lower()]}.Fruits     | {fruits}")
                print(f"    {abbr[season.lower()]}.Vegetables | {veggies}")
            else:
                print(naz)

        if not self.config.followcities:
            print("None")

        print("\nAbbreviations: SPR)ing, SUM)mer, FAL)l, WIN)ter\n")

        # print("\n")


class BasicView(Views):
    r"""Just prints"""

    def display(self) -> None:
        self.logger.info("View is running")

        # Initial data collection
        self.forecaster.get_data()

        # Data to display
        timenow: str = self.presconf.update_time()
        datenow: str = self.presconf.date
        dow: str = self.presconf.dow
        season: str = self.presconf.season
        dstmark: str = "*" if self.config.dst else ""
        # ---
        city: str = self.config.city
        province: str = self.presconf.province
        country: str = self.config.country
        sky: str = self.forecaster.data.sky
        temperature: int = self.forecaster.data.temperature
        tmin: int = self.forecaster.data.min
        tmax: int = self.forecaster.data.max
        hmin: int = self.forecaster.data.hmin
        hmax: int = self.forecaster.data.hmax
        baropressure: float = self.forecaster.data.baropressure
        hcur: int = self.forecaster.data.hcur
        tsuffix: str = self.presconf.tsuffix
        wunit: str = self.presconf.wunit
        wind: int = self.forecaster.data.wind
        winddir: str = self.forecaster.data.wind_direction
        aqi: int = self.forecaster.data.aqi
        airquality: str = self.forecaster.data.air_quality
        precipitation: int = self.forecaster.data.precipitation
        precipitation_sum: int = self.forecaster.data.precipitation_sum
        is_day: bool = self.forecaster.data.is_day
        wind_type: str = self.forecaster.data.wind_type
        precipitation_type: str = self.forecaster.data.precipitation_type
        humidity_level_min: str = self.forecaster.data.humidity_level_min
        humidity_level_max: str = self.forecaster.data.humidity_level_max
        humidity: str = self.forecaster.data.humidity
        wind_direction_long: str = self.forecaster.data.wind_direction_long
        precipitation_level: str = self.forecaster.get_precipitation_amount_assessment(
            precipitation_sum, precipitation_type
        )
        if precipitation_level != "":
            precipitation_level = f"({precipitation_level})"
        # ---
        week: list[BriefDailyForecast] = self.forecaster.data.week
        follow_cities: list = self.forecaster.data.cities_data

        holiday: str = ""
        if "holiday" in self.forecaster.data.misc_data["misc"].lower():
            holiday = f"*** {self.forecaster.data.misc_data['misc'][18:]} ***"

        # Getting the last 10 warnings
        warnings: list[list[str]] = self.warnings.get_warnings()

        day: str = "day" if is_day else "night"

        hr: str = "=================================="

        print(f"""
TODAY                        ({dow})
{hr}
Time Now           | {timenow}{dstmark} ({day}), {datenow} ({season})
Home               | {city}, {province}{country}
Sky Now            | {sky}
Temperature Now    | {temperature}°{tsuffix}
Temperatures Today | {tmin}°/{tmax}°{tsuffix}
Wind Now           | {wind_type}, {wind_direction_long} {wind}{wunit}.
Air Quality Now    | {airquality} ({aqi})
{precipitation_type:17}  | Today: {precipitation}% chance, {precipitation_sum}{self.presconf.punit} {precipitation_level}
Humidity Now       | {humidity} ({hcur}%)
Humidity Today     | {humidity_level_min}/{humidity_level_max} ({hmin}%/{hmax}%)
Barometric Press.  | Now: {baropressure} hPa
""")

        print("7-DAY FORECAST")
        print(hr)
        for day in week:
            dmin: int = day.min
            dmax: int = day.max
            dprecip: int = day.precip
            dprecip_sum: int = day.precip_sum
            dprecip_level: str = self.forecaster.get_precipitation_amount_assessment(
                dprecip_sum, precipitation_type
            )
            if dprecip_level != "":
                dprecip_level = f"({dprecip_level})"
            dow: str = day.dow

            temperatures = f"{dmin:>2}°/{dmax:>2}°{tsuffix}"
            print(
                f"  {dow}   | {temperatures:<6} | {dprecip:>3}%, {dprecip_sum}{self.presconf.punit} {dprecip_level}"
            )

        print("\nWARNINGS")
        print(hr)
        for warning in warnings:
            print(self.warnings.apply_format(warning))

        print("\nCELESTIAL")
        print(hr)
        print(
            f"Sunrise | {self.forecaster.data.misc_data['celestial']['sun']['sunrise']}"
        )
        print(
            f"Sunset  | {self.forecaster.data.misc_data['celestial']['sun']['sunset']}"
        )
        print(f"Moon    | {self.forecaster.data.misc_data['celestial']['moon']}")
        print(f"Zodiac  | {self.forecaster.data.misc_data['celestial']['zodiac']}")
        print(f"Chinese | {self.forecaster.data.misc_data['celestial']['chinese']}")

        print("\nCITIES OF INTEREST")
        print(hr)

        for city_cnt, city_data in enumerate(follow_cities):
            day: str = "(night)"
            if city_data["is_day"]:
                day = "(day)"
            temp: int = city_data["temperature"]
            # city2: str = self.config.followcities[city_cnt]["city"]
            # province2: str = self.config.followcities[city_cnt]["province"]
            # country2: str = self.config.followcities[city_cnt]["country"]
            # citytimezone: str = self.config.followcities[city_cnt]["timezone"]
            city2: str = city_data["city"]
            province2: str = city_data["province"]
            country2: str = city_data["country"]
            citytimezone: str = city_data["timezone"]
            city_time: str = self.presconf.get_time_for_timezone(citytimezone)
            # weather_code2: str = city_data["weather_code"]
            sky2: str = city_data["sky"]

            if province2.isdigit():
                province2 = ""
            elif len(province2) > 0:
                province2 = f", {province2}"

            s: str = (
                f"{city_cnt+1:>2}. {city_time} {temp:>3}°{tsuffix} {sky2:>14} {day:>7} {city2}{province2}, {country2}"
            )
            print(s)

        if not self.config.followcities:
            print("None")

        if holiday != "":
            print(f"\nNATIONAL HOLIDAY")
            print(hr)
            print(holiday)

        print("\n")


class MotivationalView(Views):
    r"""Just prints"""

    def display(self) -> None:
        self.logger.info("View is running")

        # Initial data collection
        self.forecaster.get_data()

        # Data to display
        timenow: str = self.presconf.update_time()
        datenow: str = self.presconf.date
        dow: str = self.presconf.dow
        season: str = self.presconf.season
        dstmark: str = "*" if self.config.dst else ""
        # ---
        city: str = self.config.city
        province: str = self.presconf.province
        country: str = self.config.country
        sky: str = self.forecaster.data.sky
        temperature: int = self.forecaster.data.temperature
        tmin: int = self.forecaster.data.min
        tmax: int = self.forecaster.data.max
        hmin: int = self.forecaster.data.hmin
        hmax: int = self.forecaster.data.hmax
        hcur: int = self.forecaster.data.hcur
        baropressure: float = self.forecaster.data.baropressure
        tsuffix: str = self.presconf.tsuffix
        wunit: str = self.presconf.wunit
        wind: int = self.forecaster.data.wind
        winddir: str = self.forecaster.data.wind_direction
        aqi: int = self.forecaster.data.aqi
        airquality: str = self.forecaster.data.air_quality
        precipitation: int = self.forecaster.data.precipitation
        precipitation_sum: int = self.forecaster.data.precipitation_sum
        is_day: bool = self.forecaster.data.is_day
        wind_type: str = self.forecaster.data.wind_type
        precipitation_type: str = self.forecaster.data.precipitation_type
        humidity_level_min: str = self.forecaster.data.humidity_level_min
        humidity_level_max: str = self.forecaster.data.humidity_level_max
        humidity: str = self.forecaster.data.humidity
        wind_direction_long: str = self.forecaster.data.wind_direction_long
        # ---
        # warnings: list[str] = self.forecaster.data.warnings
        week: list[BriefDailyForecast] = self.forecaster.data.week
        follow_cities: list = self.forecaster.data.cities_data

        day: str = "day" if is_day else "night"

        motivation: str = Motivator.get_motivation()
        mind_drift: str = MindDrifter.drift()

        hr: str = "============================"
        hr2: str = "~~~~~~~~~~~~~~~~~~~~~~~~~~~~"
        hr3: str = "~~,~~`~~,~~`~~,~~`~~,~~`~~,~~"

        print(f"""
By unfolding the dusty ancient scrolls, we uncover these secrets of the future:

{hr3}
{mind_drift}
as today it is a beautiful, {sky.lower()} {season} {day.lower()} in {city}, {province}{country} on {datenow} at {timenow}{dstmark}.
The current temperature is {temperature}°{tsuffix} with a forecasted range between {tmin}° and {tmax}°{tsuffix}.
The wind is {wind_type.lower()}, blowing at {wind}{wunit} from the {wind_direction_long}.
Air quality is currently {airquality.lower()} with an AQI value of {aqi}.
There is a {precipitation}% chance of {precipitation_type.lower()} today.
Humidity levels range from a {humidity_level_min.lower()} {hmin}% to a {humidity_level_max.lower()} of {hmax}%.
{hr3}
{motivation[0]}
                                            ~{motivation[1]}

{hr3}""")

        print("And by the next seven days one's fate would go like this")
        print(hr3)
        for day in week:
            dmin: int = day.min
            dmax: int = day.max
            dprecip: int = day.precip
            dprecip_sum: int = day.precip_sum
            dow: str = day.dow

            temperatures = f"{dmin:>4}° /{dmax:>4}°{tsuffix}"
            print(f"{dow} ~ {temperatures:<6} ~ {dprecip:>3}%")

        print("\n")


class DashboardView(ColorViews):
    """A dashboard color view"""

    def init_screen(self, stdscr: curses.window) -> None:
        # Global settings
        stdscr.erase()
        # stdscr.nodelay(True)
        curses.curs_set(False)  # Hide cursor
        stdscr.timeout(1000)  # Wait 1 second
        stdscr.keypad(True)  # Allow extended keyboard codes

    def screen(self, stdscr: curses.window) -> None:
        self.logger.info("View is running")

        # Initial data collection
        self.forecaster.get_data()

        theme_palette: ThemePalette = ThemePalette()
        theme_palette.init_colors()
        theme: Theme = theme_palette.get_theme(self.config.theme)
        self.init_screen(stdscr)

        # Defining the required terminal size
        # My own terminal size
        required_terminal_size: TerminalSize = DefaultTerminal()

        # Initial test for the size and to save the initial width and height
        self.test_terminal_resized(stdscr, required_terminal_size)

        last_refresh: str = ""

        # Global layout settings for this view
        first_two_windows_width: int = 38
        minimum_window_height: int = 1
        general_window_height: int = 4
        warnings_window_height: int = 11
        main_layout_columns_height: int = 6
        title_window_height: int = 3
        forecast_window_height: int = 6
        followcities_window_height: int = 12
        # history_window_height: int = 4
        healthy_window_height: int = 4
        default_text_indent: int = 1
        labels_x: int = default_text_indent
        data_x: int = labels_x + 8

        # Data which won't change
        city: str = self.config.city
        province: str = self.presconf.province
        country: str = self.config.country

        province1: str = province
        if province1.isdigit():
            province1 = ""
        elif len(province1) > 0:
            province1 = f", {province1}"

        force_screen_update: bool = False
        layout_manager: LayoutManager | None = None
        refresh_fail_counter: int = 0

        # -------------------------------------------------- VIEW MAIN LOOP
        start_time: datetime = datetime.now()
        while True:
            # User input
            keypressed: int = stdscr.getch()

            # Exit view and application
            if self.test_exit_conditions(keypressed):
                break

            # ------------------------------------------------- DEFINE LAYOUT
            # Application just started or Terminal was resized
            # keypressed == curses.KEY_RESIZE
            if not layout_manager or self.test_terminal_resized(
                stdscr, required_terminal_size
            ):
                self.logger.info("Drawing the windows...")

                layout_manager = LayoutManager(stdscr=stdscr, theme=theme)
                # layout_manager.set_three_columns()
                layout_manager.add_column(first_two_windows_width)
                layout_manager.add_column(first_two_windows_width)
                # This one will assume the remaining width
                layout_manager.add_column()

                title_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=2,
                    title="About",
                    height=title_window_height,
                    border=True,
                )
                location_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=2,
                    title="Location",
                    height=minimum_window_height,
                )
                currently_window: Window = layout_manager.add_window(
                    column_num=0,
                    title="Currently",
                    height=main_layout_columns_height,
                    border=True,
                )
                airquality_window: Window = layout_manager.add_window(
                    column_num=1,
                    title="Air & Conditions",
                    height=main_layout_columns_height,
                    border=True,
                )
                forecast_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=1,
                    title="7 Day Forecast",
                    height=forecast_window_height,
                    border=True,
                )
                followcities_window: Window = layout_manager.add_window(
                    column_num=2,
                    title="Followed cities",
                    height=followcities_window_height,
                    border=True,
                )
                warnings_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=2,
                    title="Warnings",
                    height=warnings_window_height,
                    border=True,
                )
                # history_window: Window = layout_manager.add_window(
                #     column_num=0,
                #     overlap=2,
                #     title="On This Day",
                #     height=history_window_height,
                #     border=True,
                # )
                healthy_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=2,
                    title="Healthy Living",
                    height=healthy_window_height,
                    border=True,
                )
                celestial_window: Window = layout_manager.add_window(
                    column_num=0, overlap=2, title="Celestial", height=3, border=True
                )
                misc_window: Window = layout_manager.add_window(
                    column_num=0, overlap=2, title="Misc", height=3, border=True
                )
                # brief_window = layout_manager.add_window(
                #     column_num=0, overlap=2, title="Brief", height=minimum_window_height
                # )
                lastrefresh_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=2,
                    title="Last Refresh",
                    height=minimum_window_height,
                )

                layout_manager.draw_windows()

                # Screen labels
                # About
                title_window.print(f" TUIWEATHERGIRL {__version__}")
                title_window.print("-= Weather and Disaster Station =-", align="center")
                title_window.print("Evgueni Antonov (StrayF) 2026", align="right")
                # Home location
                # location_window.print(f" Home: {city}, {province}{country}", theme="home")
                location_window.print(
                    f" Home: {city}{province1}, {country}", theme="home"
                )

                # Misc
                # brief_window.print(
                #     f"Auto-refresh: {self.weather_refresh_interval}min                    [q] Quit",
                #     x=1,
                # )
                lastrefresh_window.print(
                    f"Auto-refresh: {self.weather_refresh_interval}min   [q] Quit",
                    align="right",
                )
                lastrefresh_window.print("Last refresh: **none**", x=1, y=0)

                # Get initial additional data
                # self.get_data()

                force_screen_update = True

            current_time: datetime = datetime.now()
            elapsed: timedelta = current_time - start_time

            # ------------------------------------------------- REFRESH DATA
            # Data to display
            timenow: str = self.presconf.update_time()
            datenow: str = self.presconf.date
            dow: str = self.presconf.day_of_week
            season: str = self.presconf.season
            dstmark: str = "*" if self.config.dst else ""

            # Data update
            if elapsed >= timedelta(minutes=self.weather_refresh_interval) or (
                refresh_fail_counter > 0
                and elapsed >= timedelta(minutes=self.weather_refresh_interval_on_fail)
            ):
                if refresh_fail_counter == 0:
                    self.logger.info("--- DATA REFRESH ---")
                else:
                    self.logger.info("--- RETRYING DATA REFRESH ---")

                try:
                    self.forecaster.get_data()
                    refresh_fail_counter = 0

                    # Restoring fail refresh interval to the short one
                    self.weather_refresh_interval_on_fail = REFRESH_INTERVAL_ON_FAIL
                except Exception as e:
                    refresh_fail_counter += 1

                    if refresh_fail_counter > 3:
                        # First let's increase the refresh interval
                        # so we don't bother the APIs that much
                        self.weather_refresh_interval_on_fail = REFRESH_INTERVAL

                        warningsman: WarningsManager = WarningsManager()
                        warningsman.home_location = (
                            f"{self.config.city}-{self.config.country_code2}"
                        )
                        errmessage: list[str] = [
                            "ERROR",
                            "ALL",
                            "ERROR",
                            f"[DATA] {e}",
                        ]
                        warningsman.append(*errmessage)

                start_time: datetime = datetime.now()
                last_refresh = f"Last refresh: {datenow} {timenow}       "
                lastrefresh_window.print(last_refresh, x=1, y=0)
                force_screen_update = True

            # Time and date should always be updated, no matter what
            # Today's date and time - we need this to refresh more often
            is_day: bool = self.forecaster.data.is_day
            home_day_icon: str = self._get_daynight_icon(is_day)
            day_now: str = f"Today: {dow}, {datenow} {timenow}{dstmark} "
            day_season: str = f"{home_day_icon} {season}"
            location_window.print(
                day_now,
                x=-location_window.vislen(day_season),
                align="right",
                theme="home",
            )
            location_window.print(
                day_season, align="right"  # , theme=self._get_daynight_cp(is_day)
            )

            # --------------------------------------------- SCREEN REFRESH START
            if refresh_fail_counter == 0:
                # Technically we do not need this, but filling up the addstr()s
                # later would be more messy without it
                weather_code: int = self.forecaster.data.weather_code
                sky: str = self.forecaster.data.sky
                temperature: int = self.forecaster.data.temperature
                tmin: int = self.forecaster.data.min
                tmax: int = self.forecaster.data.max
                hmin: int = self.forecaster.data.hmin
                hmax: int = self.forecaster.data.hmax
                hcur: int = self.forecaster.data.hcur
                baropressure: float = self.forecaster.data.baropressure
                tsuffix: str = self.presconf.tsuffix
                wunit: str = self.presconf.wunit
                wind: int = self.forecaster.data.wind
                winddir: str = self.forecaster.data.wind_direction
                aqi: int = self.forecaster.data.aqi
                airquality: str = self.forecaster.data.air_quality
                precipitation: int = self.forecaster.data.precipitation
                precipitation_sum: int = self.forecaster.data.precipitation_sum
                wind_type: str = self.forecaster.data.wind_type
                precipitation_type: str = self.forecaster.data.precipitation_type
                humidity_level_min: str = self.forecaster.data.humidity_level_min
                humidity_level_max: str = self.forecaster.data.humidity_level_max
                humidity: str = self.forecaster.data.humidity
                wind_direction_long: str = self.forecaster.data.wind_direction_long
                precipitation_level: str = (
                    self.forecaster.get_precipitation_amount_assessment(
                        precipitation_sum, precipitation_type
                    )
                )
                # ---
                # warnings: list[list[str]] = self.forecaster.data.warnings
                week: list[BriefDailyForecast] = self.forecaster.data.week
                follow_cities: list = self.forecaster.data.cities_data

                # ----------------------------------------- Screen update
                # The followed cities
                for city_cnt, city_data in enumerate(follow_cities):
                    city_wx: int = 59
                    # citytimezone: str = self.config.followcities[city_cnt]["timezone"]
                    citytimezone: str = city_data["timezone"]
                    city_time: str = self.presconf.get_time_for_timezone(citytimezone)
                    followcities_window.print(f"{city_time} ", x=city_wx, y=city_cnt)

                if force_screen_update:
                    self.logger.info("--- screen update ---")
                    force_screen_update = False

                    # Current sky, temperature and temperature range
                    currently_window.clear()
                    currently_window.print("Sky   :", x=labels_x, y=0)
                    currently_window.print("Temp  :", x=labels_x, y=1)
                    currently_window.print("Range :", x=labels_x, y=2)
                    currently_window.print("Humidt:", x=labels_x, y=3)
                    currently_window.print(
                        f"{self._get_weather_description_icon(weather_code, is_day)}  {sky}",
                        x=data_x,
                        y=0,
                        theme=self._get_sky_cp(sky),
                    )
                    currently_window.print(
                        f"{temperature}°{tsuffix}",
                        x=data_x,
                        y=1,
                        theme=self._get_temp_cp(temperature, tsuffix),
                    )

                    currently_window.print(
                        f"{tmin}°{tsuffix}",
                        x=data_x,
                        y=2,
                        theme=self._get_temp_cp(tmin, tsuffix),
                    )
                    currently_window.print("/", x=data_x + 4, y=2)
                    currently_window.print(
                        f"{tmax}°{tsuffix}",
                        x=data_x + 5,
                        y=2,
                        theme=self._get_temp_cp(tmax, tsuffix),
                    )

                    # Wind, air quality and precipitation labels
                    airquality_window.clear()
                    airquality_window.print("Wind  :", x=labels_x, y=0)
                    airquality_window.print("Air Q :", x=labels_x, y=1)
                    airquality_window.print(
                        precipitation_type,
                        x=labels_x,
                        y=2,
                        theme=self._get_precipitation_type_cp(precipitation_type),
                    )
                    airquality_window.print(":", x=labels_x + 6, y=2, theme="general")
                    airquality_window.print("Humidt:", x=labels_x, y=3)

                    currently_window.print(
                        f"{self.forecaster.data.hcur}% ({humidity})",
                        x=data_x,
                        y=3,
                        theme=self._get_humidity_cp(
                            int(self.forecaster.data.hcur), int(temperature), tsuffix
                        ),
                    )
                    # Wind, air quality and precipitation
                    airquality_window.print(
                        f"{wind_type}, {winddir} {wind}{wunit}",
                        x=data_x,
                        y=0,
                        theme=self._get_wind_cp(wind, wunit),
                    )
                    airquality_window.print(
                        precipitation_type,
                        x=labels_x,
                        y=2,
                        theme=self._get_precipitation_type_cp(precipitation_type),
                    )
                    airquality_window.print(":", x=labels_x + 6, y=2, theme="general")
                    airquality_window.print(
                        f"{airquality} ({aqi})",
                        x=data_x,
                        y=1,
                        theme=self._get_aqistr_cp(airquality),
                    )
                    airquality_window.print("[", x=data_x, y=2, theme="border")
                    airquality_window.print(
                        self.prog_bar(precipitation, "●", "○"),
                        x=data_x + 1,
                        y=2,
                        theme=self._get_progbar_cp(precipitation),
                    )
                    airquality_window.print("]", x=data_x + 10, y=2, theme="border")
                    airquality_window.print(
                        f"{precipitation}%",
                        x=data_x + 11,
                        y=2,
                        theme=self._get_progbar_cp(precipitation),
                    )
                    airquality_window.print(
                        f"{precipitation_sum}{self.presconf.punit}",
                        x=data_x + 16,
                        y=2,
                        theme=self._get_precipitation_amount_cp(precipitation_level),
                    )
                    humidity_str: str = (
                        f"{self.forecaster.data.hmin}%({humidity_level_min:.7})"
                    )
                    airquality_window.print(
                        humidity_str,
                        x=data_x,
                        y=3,
                        theme=self._get_humidity_cp(
                            int(self.forecaster.data.hmin), int(tmin), tsuffix
                        ),
                    )
                    airquality_window.print("/", x=data_x + len(humidity_str), y=3)
                    airquality_window.print(
                        f"{self.forecaster.data.hmax}%({humidity_level_max:.7})",
                        x=data_x + len(humidity_str) + 1,
                        y=3,
                        theme=self._get_humidity_cp(
                            int(self.forecaster.data.hmax), int(tmax), tsuffix
                        ),
                    )

                    # 7 day forecast
                    forecast_window.clear()
                    forecast_window.draw_line(
                        x=first_two_windows_width - 2,
                        y=0,
                        direction="vertical",
                        length=4,
                        theme="border",
                    )
                    for day_cnt, day in enumerate(week):
                        wy: int = day_cnt % 4
                        wx: int = (
                            labels_x if day_cnt < 4 else first_two_windows_width + 1
                        )
                        data_x2: int = wx + 5
                        precip_x: int = data_x2 + 10

                        dmin: int = day.min
                        dmax: int = day.max
                        dprecip: int = day.precip
                        dprecip_sum: int = day.precip_sum
                        dprecip_level: str = (
                            self.forecaster.get_precipitation_amount_assessment(
                                dprecip_sum, precipitation_type
                            )
                        )
                        dow: str = day.dow

                        forecast_window.print(f"{dow}:", x=wx, y=wy)
                        forecast_window.print(
                            f"{dmin}°{tsuffix}",
                            x=data_x2,
                            y=wy,
                            theme=self._get_temp_cp(dmin, tsuffix),
                        )
                        forecast_window.print("/", x=data_x2 + 4, y=wy)
                        forecast_window.print(
                            f"{dmax}°{tsuffix}",
                            x=data_x2 + 5,
                            y=wy,
                            theme=self._get_temp_cp(dmax, tsuffix),
                        )

                        forecast_window.print("[", x=precip_x, y=wy, theme="border")
                        forecast_window.print(
                            self.prog_bar(dprecip, "●", "○", 8),
                            x=precip_x + 1,
                            y=wy,
                            theme=self._get_progbar_cp(dprecip),
                        )
                        forecast_window.print("]", x=precip_x + 9, y=wy, theme="border")
                        forecast_window.print(
                            f"{dprecip}%",
                            x=precip_x + 10,
                            y=wy,
                            theme=self._get_progbar_cp(dprecip),
                        )
                        forecast_window.print(
                            f"{dprecip_sum}",
                            x=precip_x + 14,
                            y=wy,
                            theme=self._get_precipitation_amount_cp(dprecip_level),
                        )

                    # The followed cities
                    for city_cnt, city_data in enumerate(follow_cities):
                        wy: int = city_cnt  # % 9
                        wx: int = 1  # if city_cnt < 9 else 15
                        data_x1: int = wx + 38
                        data_x2: int = data_x1 + 7
                        daynight_icon_x: int = 65

                        # day: str = "night"
                        # if city_data["is_day"]:
                        #     day = "day"
                        temp: int = city_data["temperature"]
                        # city2: str = self.config.followcities[city_cnt]["city"]
                        city2: str = city_data["city"]
                        city2 = self.presconf.abbreviate_name(city2)
                        if not "STN" in city2:
                            city2 = city2[:15]
                        # province2: str = self.config.followcities[city_cnt]["province"]
                        province2: str = city_data["province"]
                        province2 = self.presconf.abbreviate_name(province2)
                        # country2: str = self.config.followcities[city_cnt]["country"]
                        # country2_code2: str = self.config.followcities[city_cnt]["country_code2"]
                        country2: str = city_data["country"]
                        country2_code2: str = city_data["country_code2"]
                        weather_code2: str = city_data["weather_code"]
                        sky2: str = city_data["sky"]

                        if province2.isdigit():
                            province2 = ""
                        elif len(province2) > 0:
                            province2 = f", {province2}"

                        followcities_window.print(
                            f"{city_cnt+1}. {city2}{province2:.12}, {country2:.10}",
                            x=wx,
                            y=wy,
                            theme=self._get_city_cp(city2, country2_code2),
                        )
                        followcities_window.print(
                            f"{temp}°{tsuffix}".rjust(6),
                            x=data_x1,
                            y=wy,
                            theme=self._get_temp_cp(temp, tsuffix),
                        )
                        sky_condition: str = sky2
                        if sky_condition != "Clear" and sky_condition != "Cloudy":
                            sky_condition = sky_condition.replace("Clear", "Clr")
                            sky_condition = sky_condition.replace("Cloudy", "Cld")
                        # Yes, yes, very barbaric way to clear the field
                        followcities_window.print(
                            "             ",
                            x=data_x2,
                            y=wy,
                        )
                        followcities_window.print(
                            f"{self._get_weather_description_icon(weather_code2, city_data['is_day'])} {sky_condition[:10]}",
                            x=data_x2,
                            y=wy,
                            theme=self._get_sky_cp(sky2),
                        )
                        followcities_window.print(
                            # f"({day})".ljust(7),
                            self._get_daynight_icon(city_data["is_day"]),
                            x=daynight_icon_x,
                            y=wy,
                            theme=self._get_daynight_cp(city_data["is_day"]),
                        )
                    if not self.config.followcities:
                        followcities_window.print("No cities of interest", x=1, y=0)

                    # WARNINGS
                    warnings_window.clear()
                    warnings: list[list[str]] = self.warnings.get_warnings(
                        warnings_window_height - 2
                    )

                    # Sure, the window is not 99 lines high.
                    # Printing on a greater line will simply make it print on the
                    # last line. I have a safeguard to make it happen.
                    for msgy, warning in enumerate(warnings):
                        warnings_window.print(
                            self.warnings.apply_format(warning)[
                                : required_terminal_size.columns - 4
                            ],
                            x=0,
                            y=msgy,
                            newline=True,
                            theme=self._get_warnings_cp(
                                warning[2], warning[4], warning[-1]
                            ),
                        )

                    # if "histfact" in self.forecaster.data.misc_data:
                    #     history_window.clear()
                    #     history_window.print(
                    #         self.forecaster.data.misc_data["histfact"]["text"],
                    #         align="center",
                    #         x=0,
                    #         y=0,
                    #         maxlines=history_window_height - 2,
                    #     )

                    if "celestial" in self.forecaster.data.misc_data:
                        celestial_window.clear()
                        spc: str = " " * 6 + "|" + " " * 6  # Spacer
                        s: str = (
                            f'Sunrise: {self.forecaster.data.misc_data["celestial"]["sun"]["sunrise"]}{spc}Sunset: {self.forecaster.data.misc_data["celestial"]["sun"]["sunset"]}{spc}Zodiac: {self._get_zodiac_icon(self.forecaster.data.misc_data["celestial"]["zodiac"])} {self.forecaster.data.misc_data["celestial"]["zodiac"]}{spc}Chinese: {self._get_chinese_icon(self.forecaster.data.misc_data["celestial"]["chinese"])} {self.forecaster.data.misc_data["celestial"]["chinese"]}{spc}Moon: {self._get_moon_icon(self.forecaster.data.misc_data["celestial"]["moon"])}  {self.forecaster.data.misc_data["celestial"]["moon"]}'
                        )
                        celestial_window.print(s, align="center", y=0)

                    if "misc" in self.forecaster.data.misc_data:
                        mtheme: str = "general"
                        if "holiday" in self.forecaster.data.misc_data["misc"].lower():
                            # mtheme = "important"
                            mtheme = "good"
                            self.forecaster.data.misc_data["misc"] = (
                                f"*** {self.forecaster.data.misc_data['misc']} ***"
                            )

                        misc_window.clear()
                        misc_window.print(
                            self.forecaster.data.misc_data["misc"],
                            align="center",
                            x=0,
                            y=0,
                            theme=mtheme,
                        )

                    if "seasonals" in self.forecaster.data.misc_data:
                        seasonals: dict[str, list[str]] = (
                            self.forecaster.data.misc_data["seasonals"]
                        )
                        healthy: str = (
                            "Your home location is set in a Non-Agricultural Zone, so no seasonal fruits and vegetables information is available."
                        )

                        if seasonals:
                            veggies: str = ",".join(seasonals["veggies"])
                            fruits: str = ",".join(seasonals["fruits"])

                            healthy = (
                                f"SEASONAL FRUITS: {fruits} | VEGETABLES: {veggies}"
                            )

                        healthy_window.clear()
                        healthy_window.print(healthy, align="center", x=0, y=0)

                        if "health_motivation" in self.forecaster.data.misc_data:
                            health_motivation: str = self.forecaster.data.misc_data[
                                "health_motivation"
                            ]
                            healthy_window.print(
                                f"CHALLENGE: {health_motivation}",
                                align="center",
                                x=0,
                                y=1,
                            )

                # Pushing the changes
                layout_manager.refresh_screen()
                self.update_screen()
            # --------------------------------------------- SCREEN REFRESH END

    def display(self) -> None:
        curses.wrapper(self.screen)


class TTYDashboardView(ColorViews):
    """A dashboard color view for the most basic virtual console (TTY).

    This view has been specifically created for barebone
    virtual consoles (TTYs), for linux boxes without
    any desktop environment installed, such as my own
    PROJECT DEW (Debian Terminal-Only Remix).
    """

    def init_screen(self, stdscr: curses.window) -> None:
        # Global settings
        stdscr.erase()
        # stdscr.nodelay(True)
        curses.curs_set(False)  # Hide cursor
        stdscr.timeout(1000)  # Wait 1 second
        stdscr.keypad(True)  # Allow extended keyboard codes

    def screen(self, stdscr: curses.window) -> None:
        self.logger.info("View is running")

        # Initial data collection
        self.forecaster.get_data()

        theme_palette: ThemePalette = ThemePalette()
        theme_palette.init_colors()
        theme: Theme = theme_palette.get_theme(self.config.theme)
        self.init_screen(stdscr)
        required_terminal_size: TerminalSize = TTYTerminal()

        # Initial test for the size and to save the initial width and height
        self.test_terminal_resized(stdscr, required_terminal_size)

        last_refresh: str = ""

        # Global layout settings for this view
        first_two_windows_width: int = 53
        minimum_window_height: int = 1
        general_window_height: int = 4
        warnings_window_height: int = 11
        main_layout_columns_height: int = 6
        title_window_height: int = 3
        forecast_window_height: int = 6
        followcities_window_height: int = 12
        # history_window_height: int = 4
        healthy_window_height: int = 4
        default_text_indent: int = 1
        labels_x: int = 4
        data_x: int = labels_x + 12

        # Data which won't change
        city: str = self.config.city
        province: str = self.presconf.province
        country: str = self.config.country

        province1: str = province
        if province1.isdigit():
            province1 = ""
        elif len(province1) > 0:
            province1 = f", {province1}"

        force_screen_update: bool = False
        layout_manager: LayoutManager | None = None
        refresh_fail_counter: int = 0

        # -------------------------------------------------- VIEW MAIN LOOP
        start_time: datetime = datetime.now()
        while True:
            # User input
            keypressed: int = stdscr.getch()

            # Exit view and application
            if self.test_exit_conditions(keypressed):
                break

            # ------------------------------------------------- DEFINE LAYOUT
            # Application just started or Terminal was resized
            # keypressed == curses.KEY_RESIZE
            if not layout_manager or self.test_terminal_resized(
                stdscr, required_terminal_size
            ):
                self.logger.info("Drawing the windows...")

                layout_manager = LayoutManager(stdscr=stdscr, theme=theme)
                layout_manager.set_two_columns()

                title_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=1,
                    title="About",
                    height=title_window_height,
                    border=True,
                )
                location_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=1,
                    title="Location",
                    height=minimum_window_height,
                )
                currently_window: Window = layout_manager.add_window(
                    column_num=0,
                    title="Currently",
                    height=main_layout_columns_height,
                    border=True,
                )
                airquality_window: Window = layout_manager.add_window(
                    column_num=1,
                    title="Air & Conditions",
                    height=main_layout_columns_height,
                    border=True,
                )
                forecast_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=1,
                    title="7 Day Forecast",
                    height=forecast_window_height,
                    border=True,
                )
                warnings_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=1,
                    title="Warnings",
                    height=warnings_window_height,
                    border=True,
                )
                celestial_window: Window = layout_manager.add_window(
                    column_num=0, overlap=1, title="Celestial", height=3, border=True
                )
                lastrefresh_window: Window = layout_manager.add_window(
                    column_num=0,
                    overlap=1,
                    title="Last Refresh",
                    height=minimum_window_height,
                )

                layout_manager.draw_windows()

                # Screen labels
                # About
                title_window.print(f" TUIWEATHERGIRL {__version__}")
                title_window.print("-= Weather and Disaster Station =-", align="center")
                title_window.print("Evgueni Antonov (StrayF) 2026", align="right")
                # Home location
                location_window.print(
                    f" Home: {city}{province1}, {country}", theme="home"
                )

                lastrefresh_window.print(
                    f"Auto-refresh: {self.weather_refresh_interval}min   [q] Quit",
                    align="right",
                )
                lastrefresh_window.print("Last refresh: **none**", x=1, y=0)

                force_screen_update = True

            current_time: datetime = datetime.now()
            elapsed: timedelta = current_time - start_time

            # ------------------------------------------------- REFRESH DATA
            # Data to display
            timenow: str = self.presconf.update_time()
            datenow: str = self.presconf.date
            dow: str = self.presconf.day_of_week
            season: str = self.presconf.season
            dstmark: str = "*" if self.config.dst else ""

            # Data update
            if elapsed >= timedelta(minutes=self.weather_refresh_interval) or (
                refresh_fail_counter > 0
                and elapsed >= timedelta(minutes=self.weather_refresh_interval_on_fail)
            ):
                if refresh_fail_counter == 0:
                    self.logger.info("--- DATA REFRESH ---")
                else:
                    self.logger.info("--- RETRYING DATA REFRESH ---")

                try:
                    self.forecaster.get_data()
                    refresh_fail_counter = 0

                    # Restoring fail refresh interval to the short one
                    self.weather_refresh_interval_on_fail = REFRESH_INTERVAL_ON_FAIL
                except Exception as e:
                    refresh_fail_counter += 1

                    if refresh_fail_counter > 3:
                        # First let's increase the refresh interval
                        # so we don't bother the APIs that much
                        self.weather_refresh_interval_on_fail = REFRESH_INTERVAL

                        warningsman: WarningsManager = WarningsManager()
                        warningsman.home_location = (
                            f"{self.config.city}-{self.config.country_code2}"
                        )
                        errmessage: list[str] = [
                            "ERROR",
                            "ALL",
                            "ERROR",
                            f"[DATA] {e}",
                        ]
                        warningsman.append(*errmessage)

                start_time: datetime = datetime.now()
                last_refresh = f"Last refresh: {datenow} {timenow}       "
                lastrefresh_window.print(last_refresh, x=1, y=0)
                force_screen_update = True

            # Time and date should always be updated, no matter what
            # Today's date and time - we need this to refresh more often
            is_day: bool = self.forecaster.data.is_day
            home_day: str = "night"
            if is_day:
                home_day = "day"
            day_now: str = f"Today: {dow}, {datenow} {timenow}{dstmark} "
            day_season: str = f"({home_day}) {season}"
            location_window.print(
                day_now,
                x=-len(day_season),
                align="right",
                theme="home",
            )
            location_window.print(
                day_season, align="right", theme=self._get_daynight_cp(is_day)
            )

            # --------------------------------------------- SCREEN REFRESH START
            if refresh_fail_counter == 0:
                # Technically we do not need this, but filling up the addstr()s
                # later would be more messy without it
                sky: str = self.forecaster.data.sky
                temperature: int = self.forecaster.data.temperature
                tmin: int = self.forecaster.data.min
                tmax: int = self.forecaster.data.max
                hmin: int = self.forecaster.data.hmin
                hmax: int = self.forecaster.data.hmax
                hcur: int = self.forecaster.data.hcur
                baropressure: float = self.forecaster.data.baropressure
                tsuffix: str = self.presconf.tsuffix
                wunit: str = self.presconf.wunit
                wind: int = self.forecaster.data.wind
                winddir: str = self.forecaster.data.wind_direction
                aqi: int = self.forecaster.data.aqi
                airquality: str = self.forecaster.data.air_quality
                precipitation: int = self.forecaster.data.precipitation
                precipitation_sum: int = self.forecaster.data.precipitation_sum
                wind_type: str = self.forecaster.data.wind_type
                precipitation_type: str = self.forecaster.data.precipitation_type
                humidity_level_min: str = self.forecaster.data.humidity_level_min
                humidity_level_max: str = self.forecaster.data.humidity_level_max
                humidity: str = self.forecaster.data.humidity
                wind_direction_long: str = self.forecaster.data.wind_direction_long
                precipitation_level: str = (
                    self.forecaster.get_precipitation_amount_assessment(
                        precipitation_sum, precipitation_type
                    )
                )
                # ---
                # warnings: list[list[str]] = self.forecaster.data.warnings
                week: list[BriefDailyForecast] = self.forecaster.data.week
                follow_cities: list = self.forecaster.data.cities_data

                # ----------------------------------------- Screen update
                if force_screen_update:
                    self.logger.info("--- screen update ---")
                    force_screen_update = False

                    # Current sky, temperature and temperature range
                    currently_window.clear()
                    currently_window.print("Sky   :", x=labels_x, y=0)
                    currently_window.print("Temp  :", x=labels_x, y=1)
                    currently_window.print("Range :", x=labels_x, y=2)
                    currently_window.print("Humidt:", x=labels_x, y=3)
                    currently_window.print(
                        sky, x=data_x, y=0, theme=self._get_sky_cp(sky)
                    )
                    currently_window.print(
                        f"{temperature}°{tsuffix}",
                        x=data_x,
                        y=1,
                        theme=self._get_temp_cp(temperature, tsuffix),
                    )

                    currently_window.print(
                        f"{tmin}°{tsuffix}",
                        x=data_x,
                        y=2,
                        theme=self._get_temp_cp(tmin, tsuffix),
                    )
                    currently_window.print("/", x=data_x + 4, y=2)
                    currently_window.print(
                        f"{tmax}°{tsuffix}",
                        x=data_x + 5,
                        y=2,
                        theme=self._get_temp_cp(tmax, tsuffix),
                    )

                    # Wind, air quality and precipitation labels
                    airquality_window.clear()
                    airquality_window.print("Wind  :", x=labels_x, y=0)
                    airquality_window.print("Air Q :", x=labels_x, y=1)
                    airquality_window.print(
                        precipitation_type,
                        x=labels_x,
                        y=2,
                        theme=self._get_precipitation_type_cp(precipitation_type),
                    )
                    airquality_window.print(":", x=labels_x + 6, y=2, theme="general")
                    airquality_window.print("Humidt:", x=labels_x, y=3)

                    currently_window.print(
                        f"{self.forecaster.data.hcur}% ({humidity})",
                        x=data_x,
                        y=3,
                        theme=self._get_humidity_cp(
                            int(self.forecaster.data.hcur), int(temperature), tsuffix
                        ),
                    )
                    # Wind, air quality and precipitation
                    airquality_window.print(
                        f"{wind_type}, {winddir} {wind}{wunit}",
                        x=data_x,
                        y=0,
                        theme=self._get_wind_cp(wind, wunit),
                    )
                    airquality_window.print(
                        precipitation_type,
                        x=labels_x,
                        y=2,
                        theme=self._get_precipitation_type_cp(precipitation_type),
                    )
                    airquality_window.print(":", x=labels_x + 6, y=2, theme="general")
                    airquality_window.print(
                        f"{airquality} ({aqi})",
                        x=data_x,
                        y=1,
                        theme=self._get_aqistr_cp(airquality),
                    )
                    airquality_window.print("[", x=data_x, y=2, theme="border")
                    airquality_window.print(
                        self.prog_bar(precipitation),
                        x=data_x + 1,
                        y=2,
                        theme=self._get_progbar_cp(precipitation),
                    )
                    airquality_window.print("]", x=data_x + 10, y=2, theme="border")
                    airquality_window.print(
                        f"{precipitation}%",
                        x=data_x + 11,
                        y=2,
                        theme=self._get_progbar_cp(precipitation),
                    )
                    airquality_window.print(
                        f"{precipitation_sum}{self.presconf.punit}",
                        x=data_x + 16,
                        y=2,
                        theme=self._get_precipitation_amount_cp(precipitation_level),
                    )
                    humidity_str: str = (
                        f"{self.forecaster.data.hmin}%({humidity_level_min:.7})"
                    )
                    airquality_window.print(
                        humidity_str,
                        x=data_x,
                        y=3,
                        theme=self._get_humidity_cp(
                            int(self.forecaster.data.hmin), int(tmin), tsuffix
                        ),
                    )
                    airquality_window.print("/", x=data_x + len(humidity_str), y=3)
                    airquality_window.print(
                        f"{self.forecaster.data.hmax}%({humidity_level_max:.7})",
                        x=data_x + len(humidity_str) + 1,
                        y=3,
                        theme=self._get_humidity_cp(
                            int(self.forecaster.data.hmax), int(tmax), tsuffix
                        ),
                    )

                    # 7 day forecast
                    forecast_window.clear()
                    forecast_window.draw_line(
                        x=first_two_windows_width - 2,
                        y=0,
                        direction="vertical",
                        length=4,
                        theme="border",
                    )
                    for day_cnt, day in enumerate(week):
                        wy: int = day_cnt % 4
                        wx: int = (
                            labels_x if day_cnt < 4 else first_two_windows_width + 4
                        )
                        data_x2: int = wx + 5
                        precip_x: int = data_x2 + 10

                        dmin: int = day.min
                        dmax: int = day.max
                        dprecip: int = day.precip
                        dprecip_sum: int = day.precip_sum
                        dprecip_level: str = (
                            self.forecaster.get_precipitation_amount_assessment(
                                dprecip_sum, precipitation_type
                            )
                        )
                        dow: str = day.dow

                        forecast_window.print(f"{dow}:", x=wx, y=wy)
                        forecast_window.print(
                            f"{dmin}°{tsuffix}",
                            x=data_x2,
                            y=wy,
                            theme=self._get_temp_cp(dmin, tsuffix),
                        )
                        forecast_window.print("/", x=data_x2 + 4, y=wy)
                        forecast_window.print(
                            f"{dmax}°{tsuffix}",
                            x=data_x2 + 5,
                            y=wy,
                            theme=self._get_temp_cp(dmax, tsuffix),
                        )

                        forecast_window.print("[", x=precip_x, y=wy, theme="border")
                        forecast_window.print(
                            self.prog_bar(dprecip),
                            x=precip_x + 1,
                            y=wy,
                            theme=self._get_progbar_cp(dprecip),
                        )
                        forecast_window.print(
                            "]", x=precip_x + 10, y=wy, theme="border"
                        )
                        forecast_window.print(
                            f"{dprecip}%",
                            x=precip_x + 11,
                            y=wy,
                            theme=self._get_progbar_cp(dprecip),
                        )
                        forecast_window.print(
                            f"{dprecip_sum}{self.presconf.punit}",
                            x=precip_x + 15,
                            y=wy,
                            theme=self._get_precipitation_amount_cp(dprecip_level),
                        )

                    # WARNINGS
                    warnings_window.clear()
                    warnings: list[list[str]] = self.warnings.get_warnings(
                        warnings_window_height - 2
                    )

                    # Sure, the window is not 99 lines high.
                    # Printing on a greater line will simply make it print on the
                    # last line. I have a safeguard to make it happen.
                    for msgy, warning in enumerate(warnings):
                        warnings_window.print(
                            self.warnings.apply_format(warning)[
                                : required_terminal_size.columns - 4
                            ],
                            x=0,
                            y=msgy,
                            newline=True,
                            theme=self._get_warnings_cp(
                                warning[2], warning[4], warning[-1]
                            ),
                        )

                    if "celestial" in self.forecaster.data.misc_data:
                        celestial_window.clear()
                        spc: str = " " * 2 + "|" + " " * 2  # Spacer
                        s: str = (
                            f'Sunrise: {self.forecaster.data.misc_data["celestial"]["sun"]["sunrise"]}{spc}Sunset: {self.forecaster.data.misc_data["celestial"]["sun"]["sunset"]}{spc}Zodiac: {self.forecaster.data.misc_data["celestial"]["zodiac"]}{spc}Chinese: {self.forecaster.data.misc_data["celestial"]["chinese"]}{spc}Moon: {self.forecaster.data.misc_data["celestial"]["moon"]}'
                        )
                        celestial_window.print(s, align="center", y=0)

                # Pushing the changes
                layout_manager.refresh_screen()
                self.update_screen()
            # --------------------------------------------- SCREEN REFRESH END

    def display(self) -> None:
        curses.wrapper(self.screen)


class WeatherGirl:
    r"""Your daily weather girl"""

    def __init__(self, config: Configuration) -> None:
        self.view: str = config.view
        present_config: PresentationConfiguration = PresentationConfiguration(config)
        self.config: Configuration = config

        self.views: dict[str, Views] = {
            "setup": SetupView(config, present_config),
            "basic": BasicView(config, present_config),
            "seasonal": SeasonalView(config, present_config),
            "motivate": MotivationalView(config, present_config),
            "dashboard": DashboardView(config, present_config),
            "ttydashboard": TTYDashboardView(config, present_config),
        }

    def present(self, view: str = "") -> None:
        logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

        if len(view) > 0 and view not in self.views:
            raise ValueError(f"View not defined '{view}'. Run with --help for help.")

        # Updating the user preferences
        if view and view != "setup" and view != self.view:
            self.config.view = view
            self.config.save()
            logger.info(f"Default view chnaged: {view}")

        self.views[view or self.view].display()


class CommandlineParser:
    @staticmethod
    def str2bool(value: str | bool) -> bool:
        """Converts string boolean inputs into Python booleans."""
        if isinstance(value, bool):
            return value
        val_lower: str = str(value).lower()
        if val_lower in ("true", "on", "yes", "1"):
            return True
        elif val_lower in ("false", "off", "no", "0"):
            return False
        else:
            raise argparse.ArgumentTypeError(
                f"Invalid boolean value: '{value}'. Expected true/false, on/off, yes/no, 1/0."
            )

    def parse(self) -> dict[str, str | int | bool]:
        cli_parser: argparse.ArgumentParser = argparse.ArgumentParser(
            formatter_class=argparse.RawDescriptionHelpFormatter,
            prog="tuiweathergirl",
            epilog=EPILOGUE_HELP,
            description=DESCRIPTION_HELP,
        )
        action_group = cli_parser.add_mutually_exclusive_group()
        cli_parser.add_argument(
            "-ver",
            "--version",
            action="store_true",
            help="Print the version",
        )
        cli_parser.add_argument(
            "-u",
            "--updateapp",
            action="store_true",
            help="Check for updates, download and install updates.",
        )
        cli_parser.add_argument(
            "-au",
            "--autoupdateapp",
            type=self.str2bool,
            nargs="?",
            const=True,
            # default=True,
            help="Enable or disable auto-updates (on/off)",
        )
        cli_parser.add_argument(
            "--view",
            choices=[
                "setup",
                "basic",
                "motivate",
                "dashboard",
                "ttydashboard",
                "seasonal",
            ],
            default="",
            help="Select the view",
        )
        cli_parser.add_argument(
            "--theme",
            choices=["main", "mono", "lemon", "arctic"],
            default="",
            help="Select the application color scheme",
        )
        action_group.add_argument(
            "--addcity",
            dest="newcity",
            default="",
            help="Add a new city to follow (city name)",
        )
        action_group.add_argument(
            "--removecity",
            type=int,
            help="Remove a city which is currently followed (city number)",
        )
        action_group.add_argument(
            "--sethome",
            dest="homecity",
            type=int,
            help="Set which city is home (city must be already added)(city number)",
        )
        cli_parser.add_argument(
            "--country",
            dest="country",
            default="",
            help="Which country is that city in (country name)",
        )
        cli_parser.add_argument(
            "-d",
            "--debug",
            action="store_true",
            help="A bit more printing on errors",
        )
        cli_parser.add_argument(
            "-clr",
            "--clearcache",
            action="store_true",
            help="Clears the cache, the app log and the warnings log",
        )
        cli_parser.add_argument(
            "-reqt",
            "--requesttimeout",
            type=int,
            help=f"Changes the request timeout (default: {REQTIMEOUT})",
        )
        cli_parser.add_argument(
            "-ls",
            "--listfiles",
            action="store_true",
            help="List all the application files: LOG, config etc.",
        )
        cli_parser.add_argument(
            "-lsps",
            "--listpolarstations",
            action="store_true",
            help="List all the Polar Stations you can follow",
        )
        cli_parser.add_argument(
            "-lat",
            "--latitude",
            type=float,
            dest="lat",
            default=None,
            help=f"Specifies a latitude",
        )
        cli_parser.add_argument(
            "-lon",
            "--longitude",
            type=float,
            dest="lon",
            default=None,
            help=f"Specifies a longitude",
        )
        cli_parser.add_argument(
            "-csort",
            "--citiessorting",
            choices=["unsorted", "city", "country"],
            default="",
            help="Select how to sort the cities",
        )
        cli_parser.add_argument(
            "--updatepolicy",
            action="store_true",
            help="Prints the application update policy",
        )
        cli_arguments: argparse.Namespace = cli_parser.parse_args()
        args: dict[str, str | int | bool] = vars(cli_arguments)

        if args.get("version"):
            print(DESCRIPTION_HELP)
            sys.exit(0)

        if args.get("newcity"):
            args["newcity"] = args["newcity"].strip().title().replace(" Stn", " STN")
        if args.get("country"):
            args["country"] = args["country"].strip().title()

            # Proper abbreviations support
            if args["country"].upper() in ["USA", "US", "UK", "UAE", "CAR", "DRC"]:
                args["country"] = args["country"].upper()

        return args


class LocationManager:
    def __init__(self) -> None:
        self.logger: logging.Logger = logging.getLogger(
            f"{self.__module__}.{self.__class__.__qualname__}"
        )

    def set_home(self, citynum: int, config: Configuration) -> None:
        r"""Change home city"""

        if len(config.followcities) == 0:
            raise Exception(
                "There are no currently added cities. You must first add a city with `--addcity`."
            )
        if 1 <= citynum > len(config.followcities):
            raise ValueError(
                f"City must be a number between 1 and {len(config.followcities)}."
            )

        (
            config.country,
            config.followcities[citynum - 1]["country"],
        ) = (
            config.followcities[citynum - 1]["country"],
            config.country,
        )
        (
            config.country_code2,
            config.followcities[citynum - 1]["country_code2"],
        ) = (
            config.followcities[citynum - 1]["country_code2"],
            config.country_code2,
        )
        config.city, config.followcities[citynum - 1]["city"] = (
            config.followcities[citynum - 1]["city"],
            config.city,
        )
        (
            config.postal_code,
            config.followcities[citynum - 1]["postal_code"],
        ) = (
            config.followcities[citynum - 1]["postal_code"],
            config.postal_code,
        )
        (
            config.province,
            config.followcities[citynum - 1]["province"],
        ) = (
            config.followcities[citynum - 1]["province"],
            config.province,
        )
        config.lat, config.followcities[citynum - 1]["lat"] = (
            config.followcities[citynum - 1]["lat"],
            config.lat,
        )
        config.lon, config.followcities[citynum - 1]["lon"] = (
            config.followcities[citynum - 1]["lon"],
            config.lon,
        )
        (
            config.continent_code,
            config.followcities[citynum - 1]["continent_code"],
        ) = (
            config.followcities[citynum - 1]["continent_code"],
            config.continent_code,
        )
        (
            config.timezone,
            config.followcities[citynum - 1]["timezone"],
        ) = (
            config.followcities[citynum - 1]["timezone"],
            config.timezone,
        )

        config.save()  # Update config
        print(f"Home city is now set to: {config.city}, {config.country}")
        self.logger.info(f"Home city changed to: {config.city}, {config.country}")
        sys.exit(0)

    def remove_city(self, citynum: int, config: Configuration) -> None:
        r"""Remove a currently followed city"""

        if len(config.followcities) == 0:
            raise Exception(
                "There are no currently added cities. You must first add a city with `--addcity`."
            )
        if 1 <= citynum > len(config.followcities):
            raise ValueError(
                f"City must be a number between 1 and {len(config.followcities)}."
            )

        del config.followcities[citynum - 1]
        config.save()  # Update config
        self.logger.info(f"City {citynum} is unfollowed")

    def add_city(
        self,
        city: str,
        country: str,
        config: Configuration,
        lat: float | None = None,
        lon: float | None = None,
    ) -> None:
        r"""Add a new city to follow"""

        config.follow_city(city, country, lat, lon)

        for i in range(len(config.followcities)):
            locator: Locator = Locator()

            if "lat" not in config.followcities[i] or not config.followcities[i]["lat"]:
                city_entry: dict[str | int] = locator.config(
                    config,
                    config.followcities[i]["city"],
                    config.followcities[i]["country"],
                )
                config.followcities[i] = {
                    **config.followcities[i],
                    **city_entry,
                }  # Update values
            elif (
                "timezone" not in config.followcities[i]
                or not config.followcities[i]["timezone"]
            ):
                config.followcities[i]["timezone"] = ""
        config.save()  # Update config

        self.logger.info(f"Followed new city: {city}, {country}")


# ================================================================[ MAIN LOOP ]
if __name__ == "__main__":
    try:
        InstanceGuard.ensure_single_instance()
        parser: CommandlineParser = CommandlineParser()
        cli_arguments: dict[str, str | int | bool] = parser.parse()

        if cli_arguments["updatepolicy"]:
            print(APPLICATION_UPDATE_POLICY)
            sys.exit(0)

        DEBUG_MODE: str = cli_arguments["debug"]
        debug_mode_str: str = ""
        LOGLEVEL: int = logging.INFO

        if DEBUG_MODE:
            LOGLEVEL = logging.DEBUG
            debug_mode_str = "*** DEBUG MODE ENABLED ***"

        logging.basicConfig(
            filename=LOGFILENAME,
            level=LOGLEVEL,
            format="[%(asctime)s][%(levelname)s][%(name)s][%(funcName)s] %(message)s",
        )

        logger: logging.Logger = logging.getLogger(__name__)
        logger.info("")
        logger.info(
            "===================================================== SESSION START"
        )
        logger.info(f"TUIWeatherGirl {__version__} 2026 by Evgueni Antonov (StrayF)")
        logger.info(debug_mode_str)
        logger.info("")

        ExitHandler.set_handler(on_terminal_close)

        view_name: str = cli_arguments.get("view")

        # Mandatory arguments check
        lat_specified: bool = cli_arguments.get("lat") is not None
        lon_specified: bool = cli_arguments.get("lon") is not None
        city_specified: bool = bool(cli_arguments.get("newcity"))
        country_specified: bool = bool(cli_arguments.get("country"))
        if city_specified != country_specified:
            raise ValueError("City and country must be provided together.")
        if lat_specified != lon_specified:
            raise ValueError("Latitude and longitude must be provided together.")
        if (lat_specified or lon_specified) and not (
            city_specified and country_specified
        ):
            raise ValueError(
                "If latitude or longitude is specified, all four arguments (latitude, longitude, city and country) must be specified."
            )

        # This is nice to have, but not mandatory
        nasafirms_apikey: str = os.getenv(NASAFIRMS_APIKEY_ENV_VARNAME)
        if not nasafirms_apikey:
            print(
                f"NOTE: Environment variable {NASAFIRMS_APIKEY_ENV_VARNAME} is not set. You will not be able to get wildfires detailed information. Run the app with --help\n"
            )
            logger.info(
                f"NOTE: Environment variable {NASAFIRMS_APIKEY_ENV_VARNAME} is not set. You will not be able to get wildfires detailed information. Run the app with --help"
            )

        if cli_arguments["clearcache"]:
            cache: CacheManager = CacheManager()
            cache.delete()
            warnings: WarningsManager = WarningsManager()
            warnings.delete()
            if LOGFILENAME.exists():
                try:
                    with open(LOGFILENAME, "w") as f:
                        f.truncate(0)
                except Exception as e:
                    print(f"Could not reset logfile: {e}")
            print("Cache deleted, warnings log deleted, logfile reset.")
            sys.exit(0)

        if cli_arguments["listpolarstations"]:
            print("LIST OF KNOWN POLAR STATIONS")
            print("============================")
            stations: list[str] = polar_stations()
            for stn in stations:
                print(stn)
            print(
                "\nPolar stations are different, because they are operated by a country, but they reside on neutral territory."
            )
            print("\nTO ADD A POLAR STATION USE:")
            print("tuiweathergirl --addcity <STATIONNUMBER> --country polarstation\n")
            sys.exit(0)

        if cli_arguments["listfiles"]:
            cache: CacheManager = CacheManager()
            warnings: WarningsManager = WarningsManager()
            config: Configuration = Configuration()
            print(
                f"Application | This is what you are running       | {Path(__file__).resolve()}"
            )
            print(
                f"Config      | You may edit it, but no much need  | {config.filename}"
            )
            print(
                f"Warnings    | Don't edit this one!               | {warnings.filename}"
            )
            print(
                f"Cache       | Not human readable, don't edit it! | {cache.filename}"
            )
            print(f"LOG         | You may want to check this out     | {LOGFILENAME}")
            print("")
            print(
                "In case you mess-up something, just run the application with --clearcache"
            )
            sys.exit(0)

        print("\nPlease wait while loading... (might take a while)\n")

        # script_dir: str = str(Path(sys.argv[0]).resolve().parent)
        config: Configuration = Configuration()
        location_manager: LocationManager = LocationManager()

        # Attempt auto-configuration
        if not config.saved:
            locator: Locator = Locator()
            configurator: Configurator = Configurator()
            locator.config(config)
            configurator.config(config)
            config.save()

        config.load()

        if cli_arguments["citiessorting"]:
            config.cities_sorting = cli_arguments["citiessorting"]
            config.save()
            logger.info(f"Cities sorting changed to: {config.cities_sorting}")
        if cli_arguments["theme"]:
            config.theme = cli_arguments["theme"]
            config.save()
            logger.info(f"Theme changed to: {config.theme}")
        if cli_arguments["requesttimeout"]:
            REQTIMEOUT = int(cli_arguments["requesttimeout"])
            config.reqtimeout = REQTIMEOUT
            config.save()
        logger.info(f"Request timeout: {config.reqtimeout} seconds")

        # Just in case let's check what's in the config, in case
        # the user set manually some insane values
        if config.reqtimeout < 5:
            raise ValueError(
                f"Too small value for request timeout ({config.reqtimeout}). Better set values of 5 or larger."
            )
        if config.reqtimeout > 30:
            raise ValueError(
                f"Very large value for request timeout ({config.reqtimeout}). Better set values of 5 to 10."
            )

        # Location management
        if cli_arguments["newcity"]:
            view_name = "setup"
            location_manager.add_city(
                cli_arguments["newcity"],
                cli_arguments["country"],
                config,
                cli_arguments["lat"],
                cli_arguments["lon"],
            )
            cache = CacheManager()
            cache.delete()
        if cli_arguments["removecity"]:
            view_name = "setup"
            location_manager.remove_city(cli_arguments["removecity"], config)
            cache = CacheManager()
            cache.delete()
        if cli_arguments["homecity"]:
            view_name = "setup"
            location_manager.set_home(cli_arguments["homecity"], config)
            cache: CacheManager = CacheManager()
            cache.delete()
            warnings: WarningsManager = WarningsManager()
            warnings.delete()
            print("Cache and warnings log deleted.\n")
            logger.info(
                "User is setting new home city. Cache and warnings log are deleted."
            )

        if cli_arguments["autoupdateapp"] is not None:
            config.appautoupdate = cli_arguments["autoupdateapp"]
            config.save()
            status_str: dict[bool, str] = {True: "ON", False: "OFF"}
            logger.info(
                f"Application auto-update is now set to: {status_str[config.appautoupdate]}"
            )

        update_manager: UpdateManager = UpdateManager(config)
        if cli_arguments["updateapp"]:
            update_manager.force_update()
        update_manager.auto_update()

        weather_girl: WeatherGirl = WeatherGirl(config)
        weather_girl.present(view_name)

        print(
            f"TUIWEATHERGIRL {__version__} -Weather and disaster station- by Evgueni Antonov (StrayF) 2026."
        )
        print("For help: tuiweathergirl --help")
        print("Cast Spells!")

        logger.info("===================================================== SESSION END")

    except Exception as e:
        title: str = (
            f"TUIWEATHERGIRL {__version__} ==========================================[ EXCEPTION ]"
        )
        print(f"\n{title}")
        print(e)

        logger: logging.Logger = logging.getLogger(__name__)
        logger.exception("---------------------------------- EXCEPTION")

        if DEBUG_MODE:
            title2: str = (
                "---------------------------------------------------------------[ STACKTRACE ]"
            )
            print(f"\n{title2}")
            traceback.print_exc()

        sys.exit(1)
