package notify

import (
	"encoding/json"
	"fmt"
	"strings"
	"time"
)

// budapest timezone for formatting.
var budapest *time.Location

func init() {
	var err error
	budapest, err = time.LoadLocation("Europe/Budapest")
	if err != nil {
		budapest = time.FixedZone("CET", 3600)
	}
}

// ──────────────────────────────────────────────────────────────────────
// Operator email — concise, English
// ──────────────────────────────────────────────────────────────────────

// FormatOperatorEmail returns (subject, textBody) for the operator channel.
func FormatOperatorEmail(customerID, eventType, severity, message, detailsJSON string) (string, string) {
	// Icon is eventType-aware for recovery (v0.71.0, audit F11); severity stays the fallback.
	icon := "⚠️"
	switch {
	case strings.HasSuffix(eventType, "_recovered"):
		icon = "✅"
	case severity == "error" || severity == "critical":
		icon = "🔴"
	}

	subject := fmt.Sprintf("[Felhom] %s %s: %s", icon, customerID, eventType)

	now := time.Now().In(budapest).Format("2006-01-02 15:04 MST")
	body := fmt.Sprintf(`Customer: %s
Event:    %s
Severity: %s
Time:     %s
Message:  %s`, customerID, eventType, severity, now, message)

	// R-182: the backup run digest gets a rendered list instead of a raw JSON blob. It is the one
	// operator mail that carries a VARIABLE-LENGTH payload, and a dozen apps as one line of JSON is
	// unreadable on a phone at 07:00, which is the only time it matters.
	if eventType == "backup_run_failures" {
		if rendered, sub, ok := renderBackupRunFailures(customerID, detailsJSON); ok {
			return sub, body + rendered + fmt.Sprintf("\n\nDashboard: https://hub.felhom.eu/customers/%s", customerID)
		}
		// Unparseable details fall through to the raw form below rather than losing the mail. A
		// digest that renders badly still tells the operator something; a swallowed one does not.
	}

	if detailsJSON != "" && detailsJSON != "{}" {
		body += fmt.Sprintf("\nDetails:  %s", detailsJSON)
	}

	body += fmt.Sprintf("\n\nDashboard: https://hub.felhom.eu/customers/%s", customerID)

	return subject, body
}

// ──────────────────────────────────────────────────────────────────────
// Customer email — Hungarian, friendly
// ──────────────────────────────────────────────────────────────────────

// customerMessages maps event_type → Hungarian customer message.
var customerMessages = map[string]string{
	// Customer-claim arc (v0.50.0)
	"claim_lockout": "Túl sok hibás beállító kód próbálkozás történt — a beállító oldal 15 percre zárolva lett. Ha nem te próbálkoztál, jelezd az üzemeltetőnek.",
	// Backup events
	"backup_completed":        "A biztonsági mentés sikeresen elkészült.",
	"backup_failed":           "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.",
	"db_dump_completed":       "Az adatbázis mentés sikeresen elkészült.",
	"db_dump_failed":          "Az adatbázis mentés sikertelen!",
	"backup_integrity_ok":     "A mentés integritás ellenőrzés sikeres.",
	"backup_integrity_failed": "A mentés integritás ellenőrzés hibát talált!",
	"crossdrive_completed":    "A másodlagos mentés sikeresen elkészült.",
	"crossdrive_failed":       "A másodlagos mentés sikertelen!",
	// Offsite-repo continuity (controller v0.142.0)
	"offbox_repo_orphaned": "A távoli mentési tároló elárvult: a benne lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Új mentés a tároló visszaállításáig nem készül — nyisd meg a Távoli mentés oldalt.",
	"offbox_repo_reset":    "A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.",

	// Disk events (GUEST — the controller's own view) — `disk_warning` / `disk_critical`.
	//
	// DELIBERATELY NO ENTRY, from hub v0.89.0 / controller v0.191.0 (R-167, decision D-c). These two
	// types were allowlisted here, carried generic Hungarian copy, sat in the controller's
	// DefaultEnabledEvents and had a UI checkbox — and NOTHING IN ANY REPO EMITTED THEM. A complete
	// customer pipeline with no producer; the sixth "built but never wired" instance in this project.
	// The controller became their producer in v0.191.0.
	//
	// The producer sends a DYNAMIC Hungarian message naming the filesystem and its free space, so a
	// static entry here would be actively harmful: FormatCustomerEmail PREFERS the entry over the
	// message, so re-adding one would discard the drive name and the byte figures and leave the
	// customer with "A lemezterület 90% felett van" — a warning with nothing to act on. Same reason
	// `offbox_enlarge_blocked` and `disk_health_degraded` have no entry. Pinned by
	// TestDiskFillTypesHaveNoGenericCustomerMessage.

	// Host disk events (the Proxmox HOST root filesystem — distinct from the guest disk above)
	"host_disk_warning":  "A házszerver alaprendszerének (Proxmox-gazda) gyökérlemeze 90% felett van — kérjük, szabadíts fel helyet (pl. régi biztonsági mentések).",
	"host_disk_critical": "A házszerver alaprendszerének gyökérlemeze kritikusan tele van (95%+) — azonnali beavatkozás szükséges, különben a rendszer hibázhat!",

	// Per-storage fill events (a SPECIFIC tároló — mentési kötet, adatmeghajtó, tároló-készlet — telik be)
	"storage_fill_warning":  "Egy tároló a házszerveren 90% felett telt — kérjük, szabadíts fel helyet, mielőtt megtelik.",
	"storage_fill_critical": "Egy tároló a házszerveren kritikusan tele van (95%+) — a rá készülő mentések/írások meghiúsulhatnak; azonnali beavatkozás szükséges!",

	// Storage events
	"storage_disconnected": "Egy meghajtó leválasztva — a mentések szünetelhetnek.",
	"storage_reconnected":  "A meghajtó újra csatlakoztatva.",

	// E-2 — the drive the whole-system backup is written to is gone. Deliberately NOT folded into
	// storage_disconnected: the customer action differs (reconnect THIS drive, or the system backup
	// stops surviving a disk failure), and the fallback is honest about what still protects them.
	"backup_target_absent":   "A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el.",
	"backup_target_restored": "A rendszermentés meghajtója újra elérhető — a mentés folytatódik.",

	// Staleness events (Hub-generated)
	"node_stale":     "A szerver nem küldött jelentést az elmúlt időszakban.",
	"node_down":      "A szerver nem elérhető!",
	"node_recovered": "A szerver újra elérhető.",
	"host_recovered": "A házszerver alaprendszere (Proxmox-gazda) újra elérhető.",

	// Health events
	"health_degraded":  "A rendszer állapota romlott.",
	"health_critical":  "A rendszer állapota kritikus!",
	"health_recovered": "A rendszer állapota helyreállt.",

	// Controller events
	"controller_started": "A vezérlő elindult.",
	"controller_updated": "A vezérlő frissítve lett.",

	// Deadline events (Hub-generated)
	"expected_backup_missed": "A mai biztonsági mentés nem készült el a határidőig!",
	"expected_dbdump_missed": "A mai adatbázis mentés nem készült el a határidőig!",

	// App lifecycle events
	"app_deployed":     "Alkalmazás telepítve.",
	"app_removed":      "Alkalmazás eltávolítva.",
	"app_start_failed": "Egy telepített alkalmazás nem fut — ellenőrizze a rendszermonitort.",

	// Disaster recovery events
	"disaster_recovery_started":   "Katasztrófa helyreállítás elindítva.",
	"disaster_recovery_completed": "Katasztrófa helyreállítás befejezve.",

	// Test
	"test": "Ez egy teszt értesítés.",
}

// severityLabels maps severity to Hungarian labels.
var severityLabels = map[string]string{
	"info":     "Információ",
	"warning":  "Figyelmeztetés",
	"error":    "Hiba",
	"critical": "Kritikus hiba",
}

// FormatCustomerEmail returns (subject, textBody) for the customer channel.
func FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON string) (string, string) {
	label := severityLabels[severity]
	if label == "" {
		label = severity
	}

	// Use the per-event-type Hungarian message if available, otherwise fall back to message
	hunMessage := customerMessages[eventType]
	if hunMessage == "" {
		hunMessage = message
	}

	subject := fmt.Sprintf("[Felhom] %s: %s", label, hunMessage)

	now := time.Now().In(budapest).Format("2006-01-02 15:04")
	body := fmt.Sprintf(`Kedves Ügyfél!

A Felhom rendszered a következő értesítést küldte:

%s

Részletek:
- Szerver: %s
- Időpont: %s
- Szint: %s
- Típus: %s`, hunMessage, customerID, now, label, eventType)

	if message != "" && message != hunMessage {
		body += fmt.Sprintf("\n- Üzenet: %s", message)
	}

	if detailsJSON != "" && detailsJSON != "{}" {
		body += fmt.Sprintf("\n- Megjegyzés: %s", detailsJSON)
	}

	body += `

Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.

Üdvözlettel,
Felhom.eu monitoring`

	return subject, body
}

// ──────────────────────────────────────────────────────────────────────
// Customer-claim password arc (v0.50.0) — Hungarian code-delivery emails
// ──────────────────────────────────────────────────────────────────────

// FormatClaimEmail returns (subject, textBody) for a claim-arc email. kind is one of
// "claim" | "reset" | "reenroll" | "claimed" (claim.EmailKind values). The code appears ONLY in the
// returned body — callers must never log it.
//
// R-295, HUB HALF (2026-08-13). ONE NAME PER SECRET, and it is „Beállító kód".
//
// The three-word code that gives a person control of the DASHBOARD is „Beállító kód" everywhere —
// in this mail, on the operator button, and on the box's own page, which has said so since the
// controller half shipped on 2026-08-10. The TEN-word code that opens the sealed backups is
// „Helyreállítási kód" and is a different secret entirely. **„Visszaállító kód" is retired**: it was
// a near-homograph of „Helyreállítási kód", the collision cost a real code, and the hub was the last
// place it survived — this half was dropped twice before it was finished.
//
// The rule the three branches below follow: ONE SECRET IN TWO SITUATIONS KEEPS ITS NAME, AND THE
// SENTENCE AROUND IT CHANGES. „reset" and „reenroll" carry the identical secret under the identical
// name; they differ only in which page the customer will actually be looking at.
//
// THIS IS NAMING, NOT FUNCTION. No acceptance logic moved: the code is minted, hashed, rotated,
// capped and consumed exactly as before, and a reset code is still accepted on the setup page.
// Pinned by TestFormatClaimEmail_OneNamePerSecret and, on the box side, by the controller's
// claim_code_naming_test.go.
func FormatClaimEmail(kind, customerID, domain, code string) (string, string) {
	dashboardURL := "https://felhom." + domain
	switch kind {
	case "reset":
		// The box HAS a password: the customer asked for a reset from the login screen, so the
		// „Elfelejtett jelszó" link IS on their screen and naming it is correct here.
		subject := "[Felhom] Beállító kód a jelszavad visszaállításához"
		body := fmt.Sprintf(`Kedves Ügyfél!

Jelszó-visszaállítást kértél a Felhom vezérlőpultodhoz.

Beállító kód: %s

A kód 72 óráig érvényes, és egyszer használható fel. Add meg a vezérlőpult
"Elfelejtett jelszó" oldalán, majd válassz új jelszót:

%s

Ha nem te kérted, hagyd figyelmen kívül — a jelenlegi jelszavad változatlan.

Üdvözlettel,
Felhom.eu`, code, dashboardURL)
		return subject, body
	case "reenroll":
		// The box was rebuilt and has NO password, so it shows „A szerver beállítása" and serves no
		// login page — there is no „Elfelejtett jelszó" link to send anyone to. Same secret, same
		// name, the page the machine is actually showing.
		//
		// It deliberately says NOTHING about the apps or the backups. A clean-slate reinstall is
		// exactly the situation in which such a reassurance could be false, and this project has
		// spent four register rows removing promises it could not see were still true.
		subject := "[Felhom] Új beállító kód — újratelepült a szervered"
		body := fmt.Sprintf(`Kedves Ügyfél!

A Felhom szervered újratelepült, ezért a vezérlőpultod belépését újra be kell
állítani. A korábbi jelszavad már nem érvényes.

Beállító kód: %s

A kód 72 óráig érvényes, és egyszer használható fel. Nyisd meg a vezérlőpultot —
"A szerver beállítása" oldal fogad —, add meg a kódot, majd válassz új jelszót:

%s

Ha nem te telepítetted újra a szervered, vedd fel a kapcsolatot az üzemeltetővel.

Üdvözlettel,
Felhom.eu`, code, dashboardURL)
		return subject, body
	case "claimed":
		subject := "[Felhom] A vezérlőpultod mostantól jelszóval védett"
		body := fmt.Sprintf(`Kedves Ügyfél!

A Felhom vezérlőpultod beállítása elkészült — a vezérlőpultod mostantól
jelszóval védett. A megadott jelszóval tudsz bejelentkezni:

%s

Ha nem te végezted a beállítást, azonnal vedd fel a kapcsolatot az üzemeltetővel.

Üdvözlettel,
Felhom.eu`, dashboardURL)
		return subject, body
	default: // "claim"
		subject := "[Felhom] Elindult a Felhom szervered — beállító kód"
		body := fmt.Sprintf(`Kedves Ügyfél!

Elindult a Felhom szervered. A vezérlőpult első használatához add meg az
alábbi beállító kódot, majd válassz saját jelszót:

Beállító kód: %s

A kód 72 óráig érvényes, és egyszer használható fel. A vezérlőpultot itt éred el:

%s

Ha nem kaptad volna meg időben, a vezérlőpult "Új kód kérése" gombjával
kérhetsz frisset — az mindig erre az e-mail címre érkezik.

Üdvözlettel,
Felhom.eu`, code, dashboardURL)
		return subject, body
	}
}

// FormatSelfBindEmail builds the customer-facing Hungarian email carrying the self-bind capability
// link (v0.66.0, R-27 slice 1). The link is the ONLY secret here — the passphrase is never in the
// mail (the customer already holds it), and the console pairing code is read off the box screen. The
// copy tells the customer they will need both factors on the page. Adult tone, no emoji.
//
// R-323, THE THIRD NAME (2026-08-13). This mail used to call the five-word phrase „visszaállító
// jelszó" — one word away from „Visszaállító kód", which had just been retired for colliding with
// „Helyreállítási kód". It is „Tulajdonosi jelmondat" now. Two reasons, and the second is the one
// that rules out the obvious alternatives:
//
//  1. THE OLD NAME WAS FALSE. The phrase restores nothing. It proves the account owns the box being
//     linked — so the name says that.
//  2. IT MUST NOT COLLIDE WITH THE OTHER FACTOR ON THE SAME PAGE. Item 1 below is the „Párosító
//     kód". Naming this one after the same act („Összekötési jelszó") would leave the two factors a
//     customer types in one sitting distinguished only by kód-versus-jelszó — which is EXACTLY the
//     „Visszaállító kód" / „Visszaállító jelszó" shape being removed. „Fiókjelszó" is worse still:
//     there IS an account password (the dashboard login), so it would collide with a different real
//     secret. „Tulajdonosi jelmondat" is distinct from all three on BOTH axes — the stem
//     (Tulajdonosi vs Beállító / Helyreállítási / Párosító) and the noun (jelmondat vs kód / jelszó).
//
// Naming only: no acceptance logic moved, and the same phrase is still accepted. Pinned by
// TestSelfBind_ThirdSecretNaming and TestSelfBindPassphrase_StillAcceptedAfterTheRename.
func FormatSelfBindEmail(customerID, link string) (string, string) {
	subject := "[Felhom] Kösd össze a Felhom dobozodat"
	body := fmt.Sprintf(`Kedves Ügyfél!

Elkészült a Felhom dobozod, és készen áll az összekötésre. Az alábbi hivatkozáson
tudod te magad összekötni a fiókoddal — nincs szükség bejelentkezésre:

%s

A hivatkozás megnyitása után két adatot kell megadnod:

  1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható.
  2. A tulajdonosi jelmondatodat (az 5 szóból álló kifejezést), amelyet a
     beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.

A hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után
zárolódik — ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.

Ha nem te kérted ezt, hagyd figyelmen kívül ezt az e-mailt.

Üdvözlettel,
Felhom.eu`, link)
	return subject, body
}

// ──────────────────────────────────────────────────────────────────────
// R-182 — the backup run digest
// ──────────────────────────────────────────────────────────────────────

// backupRunFailure is one app's failed leg within a run.
type backupRunFailure struct {
	App    string `json:"app"`
	Leg    string `json:"leg"`
	Reason string `json:"reason"`
}

// backupRunDetails is the digest payload the controller sends.
type backupRunDetails struct {
	RunID       string             `json:"run_id"`
	RunKind     string             `json:"run_kind"`
	Failed      int                `json:"failed"`
	Attempted   int                `json:"attempted"`
	TargetPath  string             `json:"target_path"`
	UsedGB      float64            `json:"used_gb"`
	AvailGB     float64            `json:"avail_gb"`
	TotalGB     float64            `json:"total_gb"`
	UsedPercent float64            `json:"used_percent"`
	SpaceKnown  bool               `json:"space_known"`
	Apps        []backupRunFailure `json:"apps"`
}

// renderBackupRunFailures turns the digest details into an operator-readable block and a subject
// that says the count without being opened. Returns ok=false when the payload cannot be parsed or
// names no apps, so the caller can fall back to the raw rendering rather than mail an empty list.
//
// THE SUCCESS COUNT IS NOT DECORATION. "3 of 4 apps failed" is a catastrophe and "3 of 40" is a bad
// night; the list alone cannot tell them apart, and the operator's first decision — get up now, or
// look after coffee — depends entirely on which it is.
func renderBackupRunFailures(customerID, detailsJSON string) (string, string, bool) {
	if detailsJSON == "" {
		return "", "", false
	}
	var d backupRunDetails
	if err := json.Unmarshal([]byte(detailsJSON), &d); err != nil || len(d.Apps) == 0 {
		return "", "", false
	}

	kind := d.RunKind
	if kind == "" {
		kind = "backup"
	}
	subject := fmt.Sprintf("[Felhom] 🔴 %s: %d of %d apps failed to back up (%s run)",
		customerID, d.Failed, d.Attempted, kind)

	// Column-align the app names so the leg and reason line up and the block scans vertically.
	width := 0
	for _, a := range d.Apps {
		if len(a.App) > width {
			width = len(a.App)
		}
	}
	legWidth := 0
	for _, a := range d.Apps {
		if len(a.Leg) > legWidth {
			legWidth = len(a.Leg)
		}
	}

	var b strings.Builder
	fmt.Fprintf(&b, "\n\nFAILED: %d of %d apps attempted in this %s run.\n\n", d.Failed, d.Attempted, kind)
	for _, a := range d.Apps {
		reason := trimRepeatedUsage(a.Reason, d.TargetPath)
		if reason == "" {
			reason = "(no reason recorded)"
		}
		fmt.Fprintf(&b, "  %-*s  %-*s  %s\n", width, a.App, legWidth, a.Leg, reason)
	}

	// The space figures answer "is this one broken app or a full disk" before the reasons are read.
	// An absent reading renders as unavailable, never as zeros — "0 GB free" and "we could not look"
	// are opposite diagnoses (the UnitSpace rule, same reasoning, other side of the wire).
	if d.SpaceKnown {
		fmt.Fprintf(&b, "\nFilesystem: %s — %.1f/%.1f GB used (%.0f%%), %.1f GB free\n",
			d.TargetPath, d.UsedGB, d.TotalGB, d.UsedPercent, d.AvailGB)
	} else {
		fmt.Fprintf(&b, "\nFilesystem: %s — usage unavailable (the filesystem could not be read)\n", d.TargetPath)
	}

	b.WriteString("\nEvery failure above is also recorded individually in the notification log,\n")
	b.WriteString("whether or not this mail was sent.")
	return b.String(), subject, true
}

// trimRepeatedUsage strips the trailing "— /path: X/Y GB used (Z%), W GB free" clause from a per-app
// reason, because the digest prints those figures ONCE for the whole run on its own line.
//
// This is a copy fix, and it was made after reading the first real digest rather than from the
// design. The reserve's refusal message is authored for a single-app alert, where naming the
// filesystem is exactly right; repeated down a list of a dozen apps it is the same forty characters
// twelve times, and it pushes the part that differs off the right-hand edge of a phone screen at
// 07:00 — which is the only moment this mail has to work.
//
// It trims ONLY an exact "— <target path>:" suffix, so a reason that mentions a different path, or
// none, is left completely alone. A reason that is nothing but the usage clause is left alone too:
// removing everything would turn a bad line into an empty one.
func trimRepeatedUsage(reason, targetPath string) string {
	if reason == "" || targetPath == "" {
		return reason
	}
	marker := " — " + targetPath + ":"
	i := strings.LastIndex(reason, marker)
	if i <= 0 {
		return reason
	}
	return strings.TrimSpace(reason[:i])
}
