Skip to main content

num2words2_core/
lang_fi.rs

1//! Port of `lang_FI.py` (Finnish), via `lang_EUR` → `Num2Word_Base`.
2//!
3//! Shape: **self-contained**, but only just. `Num2Word_FI` *is* an engine
4//! language in spirit — it defines `high/mid/low_numwords` and a `merge` — yet
5//! it overrides `splitnum`, `clean`, `merge` and `to_cardinal` with its own
6//! four-argument variants that thread an `Options` (ordinal/case/plural/prefer)
7//! through every step. Two things make the shared `base.rs` engine unusable:
8//!
9//!   1. FI's card *values* are not strings. They are Finnish **stems** paired
10//!      with a Kotus inflection class — `("tuha", 46)` — or lists of such parts
11//!      mixed with literal text — `[("y", 31), "toista"]`. The final surface
12//!      form is only produced by [`inflect`], at merge time, once the required
13//!      grammatical case is known. `base::Cards` maps `BigInt -> String`, so it
14//!      cannot carry them.
15//!   2. FI keeps a **second** table, `self.ords`, parallel to `self.cards`.
16//!      `splitnum` picks one or the other from `options.ordinal`, so ordinals
17//!      are produced by re-running the whole engine over a different table
18//!      rather than by post-processing the cardinal.
19//!
20//! So `cards()`/`maxval()`/`merge()` stay at their trait defaults here and this
21//! file carries private ports of `splitnum`/`clean`/`merge`/`inflect` plus its
22//! own [`Table`] type. The tree shape and the fold order are otherwise
23//! identical to `base.rs` — see the comments there.
24//!
25//! # Inherited behaviour
26//!
27//!   * `Num2Word_EUR.setup` supplies `high_numwords` = `["cent"] +
28//!     gen_high_numwords(units, tens, lows)` (100 Latin stems). EUR's
29//!     `set_high_numwords` is **not** used — FI overrides it (see
30//!     [`translate_high`]).
31//!   * `Num2Word_Base.verify_ordinal` raises `TypeError` on negatives; FI's
32//!     `to_ordinal` calls it, hence `to_ordinal(-1)` → `N2WError::Type`.
33//!   * `Num2Word_Base.title` is a no-op because `is_title` is never set true.
34//!   * `MAXVAL = 1000 * list(self.cards.keys())[0]` = 1000 · 10^600 = **10^603**.
35//!   * The **float/Decimal cardinal path is inherited unchanged.** FI overrides
36//!     only `to_cardinal` (the integer engine, above); it defines neither
37//!     `to_cardinal_float` nor `float2tuple`, so `Num2Word_Base.to_cardinal_float`
38//!     applies verbatim — which is exactly what the trait default
39//!     [`Lang::to_cardinal_float`] → `floatpath::default_to_cardinal_float`
40//!     ports. That default calls back into this file's `to_cardinal` for the
41//!     integer part and each fractional digit, and reads `negword` ("miinus ")
42//!     and `pointword` ("pilkku") from the impl below, so `0.5` →
43//!     "nolla pilkku viisi", `-0.5` → "miinus nolla pilkku viisi", `2.675` →
44//!     "kaksi pilkku kuusi seitsemän viisi" (the f64-artefact `.675` rescued by
45//!     the `< 0.01` heuristic), and the trillion-scale Decimal
46//!     `98746251323029.99` round-trips exactly. Since `is_title` is false,
47//!     `title(pointword)` leaves "pilkku" untouched. Verified against the frozen
48//!     corpus (`cardinal_dec` + float `cardinal` rows) and the live interpreter;
49//!     no override is needed here and adding one would only risk divergence.
50//!
51//! # Faithfully reproduced Python bugs
52//!
53//! `Num2Word_FI.set_high_numwords` rewrites the Latin stems into Finnish. Its
54//! `octo`/`nove` arms mean to reach the "two from twenty" / "one from twenty"
55//! forms by looking 10 entries back in the *decade* order, but they are wrong
56//! in two ways, and both are preserved verbatim by [`translate_high`]:
57//!
58//! 1. The arms do `numword = high[i + -10]` and then slice `"octo"`/`"nove"`
59//!    off **that** word — which generally does not start with `octo`/`nove` at
60//!    all, so the slice removes 4 (resp. 5) arbitrary characters. Hence
61//!    10^480 is "duodegintiljoona" (from `high[10]` = "nonagint" → "duode" +
62//!    "gint"), 10^474 is "undetogintiljoona" (from "novoctogint" → "unde" +
63//!    "togint") and 10^468 is "duodektogintiljoona".
64//! 2. For `i < 10` Python's `high[i - 10]` wraps around to the **end** of the
65//!    list — the short `lows` stems. So 10^594 = "undeiljoona" (`high[-9]` =
66//!    "non", sliced to "") and 10^588 = "duodeiljoona" (`high[-8]` = "oct",
67//!    sliced to ""), both missing their scale name entirely. [`wrap_index`]
68//!    reproduces Python's negative indexing.
69//!
70//! Two more oddities that are *not* bugs but look like them, kept as-is:
71//! `"sexagint"` → `"seagint"` → **"seagintiljoona"** (10^360), because the
72//! blanket `replace("sex", "se")` also eats the `sex` of the decade name; and
73//! `"septrigint"` → **"septenrigintiljoona"** (10^222), because the `sept` arm
74//! re-prefixes a stem whose `sept` was already elided by EUR.
75//!
76//! Every high stem in this file is generated, not transcribed, and the
77//! generator was diffed against the live Python `cards`/`ords` tables for all
78//! 101 high entries.
79//!
80//! # Currency
81//!
82//! `Num2Word_FI` declares its **own** class-body `CURRENCY_FORMS`, which
83//! shadows `Num2Word_EUR.CURRENCY_FORMS`. So the `lang_EUR` trap — where
84//! `Num2Word_EN.__init__` mutates the shared EUR class dict in place and 16
85//! other classes silently read the English result — **does not reach FI**:
86//! `Num2Word_FI.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` is `False`, and
87//! the live table holds `("euro", "euroa")`, not EN's `("euro", "euros")`.
88//! This file therefore transcribes `lang_FI.py` literally, loops included.
89//!
90//! FI defines no `CURRENCY_PRECISION`, so `Num2Word_Base`'s empty dict applies
91//! and `.get(code, 100)` yields **100 for every code** — the trait default.
92//! Deliberately not overridden: JPY is a *2-decimal* currency here (its rare
93//! `sen` subunit is kept), so `default_to_currency`'s `divisor == 1` branch is
94//! unreachable, and KWD/BHD are simply absent from the table rather than being
95//! 3-decimal — they raise NotImplementedError.
96//!
97//! `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
98//! inherited from `Num2Word_Base` unchanged; `pluralize` from `Num2Word_EUR`.
99//! Only `to_currency` is overridden — see [`LangFi::to_currency`].
100//!
101//! # Float/Decimal entry routing
102//!
103//! `to_cardinal` routes on `assert int(value) == value` — whole values take
104//! the integer engine, fractional ones `to_cardinal_float` — which is exactly
105//! the trait-default `cardinal_float_entry`, so that hook stays untouched. The
106//! other three modes need overrides:
107//!
108//!   * `to_ordinal(float/Decimal)` runs `Num2Word_Base.verify_ordinal` first:
109//!     fractional → `TypeError` (`errmsg_floatord`), negative → `TypeError`
110//!     (`errmsg_negord`). `abs(-0.0) == -0.0` is true in Python, so **-0.0 is
111//!     a valid ordinal input** ("nollas") — the override must not use the
112//!     sign-bit-aware `FloatValue::is_negative`. Whole non-negative floats run
113//!     the ordinal engine (`to_ordinal(5.0)` == "viides").
114//!   * `to_ordinal_num(float/Decimal)` is `str(value) + "."` with no
115//!     validation at all, so `to_ordinal_num(-1.5)` == "-1.5." — the override
116//!     appends "." to the Python repr the binding computed.
117//!   * `to_year(float/Decimal)`: `val < 0` (false for -0.0!) flips to abs +
118//!     " ennen ajanlaskun alkua", then `to_cardinal(val).replace(" ", "")` —
119//!     so fractional years glue the decimal grammar together:
120//!     `to_year(0.5)` == "nollapilkkuviisi".
121//!
122//! String inputs need no hook: FI does not override `str_to_number`, so the
123//! default `Decimal(str)` parse feeds the same float entries.
124//!
125//! # Grammatical kwargs
126//!
127//! `to_cardinal`/`to_ordinal` accept `(case="nominative", plural=False,
128//! prefer=None)`, `to_ordinal_num` `(case, plural)` (both **unread** — no
129//! validation, still `str(value) + "."`), `to_year` `(suffix=None,
130//! longval=True)` (`longval` unread). Quirks reproduced by the `*_kw` hooks:
131//!
132//!   * `case` goes through `NAME_TO_CASE[case]` — an unknown name (or a
133//!     non-string like `None`) raises **KeyError**, and in `to_ordinal` that
134//!     lookup happens *before* `verify_ordinal`, so
135//!     `to_ordinal(-5, case="bogus")` is KeyError, not TypeError.
136//!   * `case="accusative"` maps to ACC=12, which **no** KOTUS table defines,
137//!     so it dies later with `KeyError: 12` inside `inflect`.
138//!   * `to_cardinal(<fractional float>, case=<non-nominative>)` raises
139//!     `NotImplementedError`; with nominative it falls to `to_cardinal_float`,
140//!     silently ignoring `plural`/`prefer`.
141//!   * `prefer=None` behaves as empty (`options.prefer or set()`).
142//!   * `to_year(-n, suffix=s)` keeps the caller's suffix — the BC default only
143//!     fills in when `suffix` is falsy (`suffix = suffix or ...` twice).
144//!
145//! # Cross-call state
146//!
147//! None. `Num2Word_FI` has no `_pending_*` handshake; `self.ords` is built once
148//! in `__init__` and only read afterwards.
149
150use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
151use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
152use crate::floatpath::FloatValue;
153use num_bigint::BigInt;
154use num_integer::Integer;
155use num_traits::{One, Signed, Zero};
156use std::collections::HashMap;
157
158// ---------------------------------------------------------------------------
159// Grammatical cases (module-level constants in lang_FI.py)
160// ---------------------------------------------------------------------------
161
162const NOM: u8 = 10; // nominative: the dictionary form
163const GEN: u8 = 11; // genitive: ~of/'s
164/// accusative: Python's `NAME_TO_CASE` maps "accusative" to 12, but **no**
165/// `KOTUS_TYPE` entry defines it, so `to_cardinal(n, case="accusative")` dies
166/// with `KeyError: 12` inside `inflect` — see [`kotus_suffixes`], which
167/// returns that `KeyError`. Reachable only through the `*_kw` hooks.
168const ACC: u8 = 12;
169const PTV: u8 = 13; // partitive: as an object
170const INE: u8 = 14; // inessive: in
171const ELA: u8 = 15; // elative: from/out of
172const ILL: u8 = 16; // illative: into
173const ADE: u8 = 17; // adessive: at/on
174const ABL: u8 = 18; // ablative: from
175const ALL: u8 = 19; // allative: to
176const ESS: u8 = 20; // essive: as (in the role of)
177const TRANSL: u8 = 21; // translative: to (the role of)
178const INSTRUC: u8 = 22; // instructive: with
179const ABE: u8 = 23; // abessive: without
180const COM: u8 = 24; // comitative: together with
181
182/// `NAME_TO_CASE[case]`. A miss is Python's `KeyError: 'bogus'` — the repr'd
183/// key is the whole message.
184fn name_to_case(name: &str) -> Result<u8> {
185    Ok(match name {
186        "nominative" => NOM,
187        "genitive" => GEN,
188        "accusative" => ACC,
189        "partitive" => PTV,
190        "inessive" => INE,
191        "elative" => ELA,
192        "illative" => ILL,
193        "adessive" => ADE,
194        "ablative" => ABL,
195        "allative" => ALL,
196        "essive" => ESS,
197        "translative" => TRANSL,
198        "instructive" => INSTRUC,
199        "abessive" => ABE,
200        "comitative" => COM,
201        other => return Err(N2WError::Key(format!("'{}'", other))),
202    })
203}
204
205/// `BACK_TO_FRONT`: vowel harmony. Insertion order matters only in that the
206/// replacements are applied in sequence; `ä`/`ö`/`y` are never re-matched, so
207/// they cannot cascade.
208const BACK_TO_FRONT: [(char, &str); 3] = [('a', "ä"), ('o', "ö"), ('u', "y")];
209
210// ---------------------------------------------------------------------------
211// KOTUS_TYPE — nominal inflection tables
212// ---------------------------------------------------------------------------
213
214/// One `CASE: (SINGULAR_SUFFIX+, PLURAL_SUFFIX+)` row.
215///
216/// Python stores either a plain `str` or a `tuple` of alternatives per slot.
217/// Both are modelled as a slice: length 1 is the plain string, length > 1 is
218/// the tuple of choices resolved against `options.prefer` in [`inflect`].
219type Row = (u8, &'static [&'static str], &'static [&'static str]);
220
221/// Kotus type 5/risti, no gradation.
222const K5: &[Row] = &[
223    (NOM, &["i"], &["it"]),
224    (GEN, &["in"], &["ien"]),
225    (PTV, &["ia"], &["eja"]),
226    (INE, &["issa"], &["eissa"]),
227    (ELA, &["ista"], &["eista"]),
228    (ILL, &["iin"], &["eihin"]),
229    (ADE, &["illa"], &["eilla"]),
230    (ABL, &["ilta"], &["eilta"]),
231    (ALL, &["ille"], &["eille"]),
232    (ESS, &["ina"], &["eina"]),
233    (TRANSL, &["iksi"], &["eiksi"]),
234    (INSTRUC, &["ein"], &["ein"]),
235    (ABE, &["itta"], &["eitta"]),
236    (COM, &["eine"], &["eine"]),
237];
238
239/// Kotus type 7/ovi, no gradation. Only reachable as the plural half of the
240/// derived type 108.
241const K7: &[Row] = &[
242    (NOM, &["i"], &["et"]),
243    (GEN, &["en"], &["ien"]),
244    (PTV, &["ea"], &["ia"]),
245    (INE, &["essa"], &["issa"]),
246    (ELA, &["esta"], &["ista"]),
247    (ILL, &["een"], &["iin"]),
248    (ADE, &["ella"], &["illa"]),
249    (ABL, &["elta"], &["ilta"]),
250    (ALL, &["elle"], &["ille"]),
251    (ESS, &["ena"], &["ina"]),
252    (TRANSL, &["eksi"], &["iksi"]),
253    (INSTRUC, &["in"], &["in"]),
254    (ABE, &["etta"], &["itta"]),
255    (COM, &["ine"], &["ine"]),
256];
257
258/// Kotus type 8/nalle, no gradation. Only reachable as the singular half of
259/// the derived type 108.
260const K8: &[Row] = &[
261    (NOM, &["e"], &["et"]),
262    (GEN, &["en"], &["ejen", "ein"]),
263    (PTV, &["ea"], &["eja"]),
264    (INE, &["essa"], &["eissa"]),
265    (ELA, &["esta"], &["eista"]),
266    (ILL, &["een"], &["eihin"]),
267    (ADE, &["ella"], &["eilla"]),
268    (ABL, &["elta"], &["eilta"]),
269    (ALL, &["elle"], &["eille"]),
270    (ESS, &["ena"], &["eina"]),
271    (TRANSL, &["eksi"], &["eiksi"]),
272    (INSTRUC, &["ein"], &["ein"]),
273    (ABE, &["etta"], &["eitta"]),
274    (COM, &["eine"], &["eine"]),
275];
276
277/// Kotus type 9/kala, t-d gradation (sata). Keyed 109 in Python.
278const K109: &[Row] = &[
279    (NOM, &["ta"], &["dat"]),
280    (GEN, &["dan"], &["tojen", "tain"]),
281    (PTV, &["taa"], &["toja"]),
282    (INE, &["dassa"], &["doissa"]),
283    (ELA, &["dasta"], &["doista"]),
284    (ILL, &["taan"], &["toihin"]),
285    (ADE, &["dalla"], &["doilla"]),
286    (ABL, &["dalta"], &["doilta"]),
287    (ALL, &["dalle"], &["doille"]),
288    (ESS, &["tana"], &["toina"]),
289    (TRANSL, &["daksi"], &["doiksi"]),
290    (INSTRUC, &["doin"], &["doin"]),
291    (ABE, &["datta"], &["doitta"]),
292    (COM, &["toine"], &["toine"]),
293];
294
295/// Kotus type 10/koira, no gradation.
296const K10: &[Row] = &[
297    (NOM, &["a"], &["at"]),
298    (GEN, &["an"], &["ien", "ain"]),
299    (PTV, &["aa"], &["ia"]),
300    (INE, &["assa"], &["issa"]),
301    (ELA, &["asta"], &["ista"]),
302    (ILL, &["aan"], &["iin"]),
303    (ADE, &["alla"], &["illa"]),
304    (ABL, &["alta"], &["ilta"]),
305    (ALL, &["alle"], &["ille"]),
306    (ESS, &["ana"], &["ina"]),
307    (TRANSL, &["aksi"], &["iksi"]),
308    (INSTRUC, &["in"], &["in"]),
309    (ABE, &["atta"], &["itta"]),
310    (COM, &["ine"], &["ine"]),
311];
312
313/// Kotus type 27/käsi, t-d gradation.
314const K27: &[Row] = &[
315    (NOM, &["si"], &["det"]),
316    (GEN, &["den"], &["sien", "tten"]),
317    (PTV, &["tta"], &["sia"]),
318    (INE, &["dessa"], &["sissa"]),
319    (ELA, &["desta"], &["sista"]),
320    (ILL, &["teen"], &["siin"]),
321    (ADE, &["della"], &["silla"]),
322    (ABL, &["delta"], &["silta"]),
323    (ALL, &["delle"], &["sille"]),
324    (ESS, &["tena"], &["sina"]),
325    (TRANSL, &["deksi"], &["siksi"]),
326    (INSTRUC, &["sin"], &["sin"]),
327    (ABE, &["detta"], &["sitta"]),
328    (COM, &["sine"], &["sine"]),
329];
330
331/// Kotus type 31/kaksi, t-d gradation.
332const K31: &[Row] = &[
333    (NOM, &["ksi"], &["hdet"]),
334    (GEN, &["hden"], &["ksien"]),
335    (PTV, &["hta"], &["ksia"]),
336    (INE, &["hdessa"], &["ksissa"]),
337    (ELA, &["hdesta"], &["ksista"]),
338    (ILL, &["hteen"], &["ksiin"]),
339    (ADE, &["hdella"], &["ksilla"]),
340    (ABL, &["hdelta"], &["ksilta"]),
341    (ALL, &["hdelle"], &["ksille"]),
342    (ESS, &["htena"], &["ksina"]),
343    (TRANSL, &["hdeksi"], &["ksiksi"]),
344    (INSTRUC, &["ksin"], &["ksin"]),
345    (ABE, &["hdetta"], &["ksitta"]),
346    (COM, &["ksine"], &["ksine"]),
347];
348
349/// Kotus type 32/sisar, no gradation. Only reachable as the base of the
350/// derived type 132.
351const K32: &[Row] = &[
352    (NOM, &[""], &["et"]),
353    (GEN, &["en"], &["ien", "ten"]),
354    (PTV, &["ta"], &["ia"]),
355    (INE, &["essa"], &["issa"]),
356    (ELA, &["esta"], &["ista"]),
357    (ILL, &["een"], &["iin"]),
358    (ADE, &["ella"], &["illa"]),
359    (ABL, &["elta"], &["ilta"]),
360    (ALL, &["elle"], &["ille"]),
361    (ESS, &["ena"], &["ina"]),
362    (TRANSL, &["eksi"], &["iksi"]),
363    (INSTRUC, &["in"], &["in"]),
364    (ABE, &["etta"], &["itta"]),
365    (COM, &["ine"], &["ine"]),
366];
367
368/// Kotus type 38/nainen, no gradation.
369const K38: &[Row] = &[
370    (NOM, &["nen"], &["set"]),
371    (GEN, &["sen"], &["sten", "sien"]),
372    (PTV, &["sta"], &["sia"]),
373    (INE, &["sessa"], &["sissa"]),
374    (ELA, &["sesta"], &["sista"]),
375    (ILL, &["seen"], &["siin"]),
376    (ADE, &["sella"], &["silla"]),
377    (ABL, &["selta"], &["silta"]),
378    (ALL, &["selle"], &["sille"]),
379    (ESS, &["sena"], &["sina"]),
380    (TRANSL, &["seksi"], &["siksi"]),
381    (INSTRUC, &["sin"], &["sin"]),
382    (ABE, &["setta"], &["sitta"]),
383    (COM, &["sine"], &["sine"]),
384];
385
386/// Kotus type 45/kahdeksas, nt-nn gradation. Carries every ordinal stem.
387const K45: &[Row] = &[
388    (NOM, &["s"], &["nnet"]),
389    (GEN, &["nnen"], &["nsien"]),
390    (PTV, &["tta"], &["nsia"]),
391    (INE, &["nnessa"], &["nsissa"]),
392    (ELA, &["nnesta"], &["nsista"]),
393    (ILL, &["nteen"], &["nsiin"]),
394    (ADE, &["nnella"], &["nsilla"]),
395    (ABL, &["nnelta"], &["nsilta"]),
396    (ALL, &["nnelle"], &["nsille"]),
397    (ESS, &["ntena"], &["nsina"]),
398    (TRANSL, &["nneksi"], &["nsiksi"]),
399    (INSTRUC, &["nsin"], &["nsin"]),
400    (ABE, &["nnetta"], &["nsitta"]),
401    (COM, &["nsine"], &["nsine"]),
402];
403
404/// Kotus type 46/tuhat, nt-nn gradation.
405const K46: &[Row] = &[
406    (NOM, &["t"], &["nnet"]),
407    (GEN, &["nnen"], &["nsien", "nten"]),
408    (PTV, &["tta"], &["nsia"]),
409    (INE, &["nnessa"], &["nsissa"]),
410    (ELA, &["nnesta"], &["nsista"]),
411    (ILL, &["nteen"], &["nsiin"]),
412    (ADE, &["nnella"], &["nsilla"]),
413    (ABL, &["nnelta"], &["nsilta"]),
414    (ALL, &["nnelle"], &["nsille"]),
415    (ESS, &["ntena"], &["nsina"]),
416    (TRANSL, &["nneksi"], &["nsiksi"]),
417    (INSTRUC, &["nsin"], &["nsin"]),
418    (ABE, &["nnetta"], &["nsitta"]),
419    (COM, &["nsine"], &["nsine"]),
420];
421
422/// kolme. Python builds this at import time as
423/// `{c: (KOTUS_TYPE[8][c][0], KOTUS_TYPE[7][c][1]) for c in KOTUS_TYPE[8]}` —
424/// singular column from type 8, plural column from type 7 — then overrides
425/// INSTRUC/ABE/COM. Written out literally here and verified cell-by-cell
426/// against the live `lang_FI.KOTUS_TYPE[108]`.
427///
428/// Note the GEN plural is type 7's plain `"ien"`, **not** type 8's
429/// `("ejen", "ein")` tuple, and that ACC (12) is absent because type 8 has no
430/// ACC key for the comprehension to iterate.
431const K108: &[Row] = &[
432    (NOM, &["e"], &["et"]),
433    (GEN, &["en"], &["ien"]),
434    (PTV, &["ea"], &["ia"]),
435    (INE, &["essa"], &["issa"]),
436    (ELA, &["esta"], &["ista"]),
437    (ILL, &["een"], &["iin"]),
438    (ADE, &["ella"], &["illa"]),
439    (ABL, &["elta"], &["ilta"]),
440    (ALL, &["elle"], &["ille"]),
441    (ESS, &["ena"], &["ina"]),
442    (TRANSL, &["eksi"], &["iksi"]),
443    // the three explicit overrides that follow the comprehension
444    (INSTRUC, &["en"], &["in"]),
445    (ABE, &["etta"], &["itta"]),
446    (COM, &["ine"], &["ine"]),
447];
448
449/// seitsemän, kahdeksan, yhdeksän. `KOTUS_TYPE[10].copy()` with NOM replaced
450/// by `("an", "at")`.
451const K110: &[Row] = &[
452    (NOM, &["an"], &["at"]),
453    (GEN, &["an"], &["ien", "ain"]),
454    (PTV, &["aa"], &["ia"]),
455    (INE, &["assa"], &["issa"]),
456    (ELA, &["asta"], &["ista"]),
457    (ILL, &["aan"], &["iin"]),
458    (ADE, &["alla"], &["illa"]),
459    (ABL, &["alta"], &["ilta"]),
460    (ALL, &["alle"], &["ille"]),
461    (ESS, &["ana"], &["ina"]),
462    (TRANSL, &["aksi"], &["iksi"]),
463    (INSTRUC, &["in"], &["in"]),
464    (ABE, &["atta"], &["itta"]),
465    (COM, &["ine"], &["ine"]),
466];
467
468/// kymmenen. `KOTUS_TYPE[32].copy()` with NOM replaced by `("en", "et")`.
469const K132: &[Row] = &[
470    (NOM, &["en"], &["et"]),
471    (GEN, &["en"], &["ien", "ten"]),
472    (PTV, &["ta"], &["ia"]),
473    (INE, &["essa"], &["issa"]),
474    (ELA, &["esta"], &["ista"]),
475    (ILL, &["een"], &["iin"]),
476    (ADE, &["ella"], &["illa"]),
477    (ABL, &["elta"], &["ilta"]),
478    (ALL, &["elle"], &["ille"]),
479    (ESS, &["ena"], &["ina"]),
480    (TRANSL, &["eksi"], &["iksi"]),
481    (INSTRUC, &["in"], &["in"]),
482    (ABE, &["etta"], &["itta"]),
483    (COM, &["ine"], &["ine"]),
484];
485
486/// `KOTUS_TYPE[kotus_type][case]`, reproducing both `KeyError`s.
487fn kotus_suffixes(kotus: u16, case: u8) -> Result<(&'static [&'static str], &'static [&'static str])> {
488    let rows: &'static [Row] = match kotus {
489        5 => K5,
490        7 => K7,
491        8 => K8,
492        10 => K10,
493        27 => K27,
494        31 => K31,
495        32 => K32,
496        38 => K38,
497        45 => K45,
498        46 => K46,
499        108 => K108,
500        109 => K109,
501        110 => K110,
502        132 => K132,
503        _ => return Err(N2WError::Key(format!("{}", kotus))),
504    };
505    for &(c, sg, pl) in rows {
506        if c == case {
507            return Ok((sg, pl));
508        }
509    }
510    // Only ACC (12) can land here, and only if a caller supplies
511    // case="accusative" — which this trait cannot. Python: `KeyError: 12`.
512    Err(N2WError::Key(format!("{}", case)))
513}
514
515// ---------------------------------------------------------------------------
516// Card / ord values
517// ---------------------------------------------------------------------------
518
519/// One element of a card value.
520///
521/// Python mixes three things in the same list: a bare `str` ("toista"), a
522/// 2-tuple `(stem, kotus_type)`, and a 3-tuple `(stem, kotus_type, case)` whose
523/// third slot overrides the case for singular nominative only.
524///
525/// [`Part::Text`] additionally carries merge output: `merge` replaces a value
526/// with the already-inflected surface string, and Python then re-feeds that
527/// string to `inflect`, where the `not isinstance(part, tuple)` arm passes it
528/// through untouched. So `inflect` is idempotent on merged text, and modelling
529/// merged text as a `Text` part preserves that exactly.
530#[derive(Debug, Clone)]
531enum Part {
532    Text(String),
533    Stem {
534        stem: String,
535        kotus: u16,
536        /// The 3-tuple's `part[2]`.
537        case_override: Option<u8>,
538    },
539}
540
541/// Python's card value: a single part or a list of parts. `inflect` normalises
542/// the former with `if not isinstance(parts, list): parts = [parts]`, so one
543/// uniform `Vec` covers both.
544type Value = Vec<Part>;
545
546fn text(s: impl Into<String>) -> Value {
547    vec![Part::Text(s.into())]
548}
549
550fn stem(s: &str, kotus: u16) -> Part {
551    Part::Stem { stem: s.to_string(), kotus, case_override: None }
552}
553
554/// The 3-tuple form, e.g. `("kymmen", 132, PTV)`.
555fn stem_case(s: &str, kotus: u16, case: u8) -> Part {
556    Part::Stem { stem: s.to_string(), kotus, case_override: Some(case) }
557}
558
559// ---------------------------------------------------------------------------
560// Options
561// ---------------------------------------------------------------------------
562
563/// Port of `lang_FI.Options`.
564///
565/// `case` is always [`NOM`] and `plural` always false when driven through the
566/// `Lang` trait, whose four modes take no grammatical arguments. `merge`'s
567/// multiplication arm still flips `case` to [`PTV`] internally, so both are
568/// live. `prefer` is always empty, which makes the tuple-of-alternatives
569/// resolution in [`inflect`] always fall to the first choice — exactly what
570/// Python does for `prefer=None`.
571#[derive(Debug, Clone)]
572struct Options {
573    ordinal: bool,
574    case: u8,
575    plural: bool,
576    prefer: Vec<String>,
577}
578
579impl Options {
580    /// `Options.variation(case=...)`: everything else is inherited.
581    fn with_case(&self, case: u8) -> Options {
582        Options { case, ..self.clone() }
583    }
584}
585
586// ---------------------------------------------------------------------------
587// inflect
588// ---------------------------------------------------------------------------
589
590/// Port of `lang_FI.inflect`.
591fn inflect(parts: &Value, options: &Options) -> Result<String> {
592    let mut out = String::new();
593    for part in parts {
594        let (stem, kotus, case_override) = match part {
595            // "part is plain text, concat and continue"
596            Part::Text(t) => {
597                out.push_str(t);
598                continue;
599            }
600            Part::Stem { stem, kotus, case_override } => (stem, *kotus, *case_override),
601        };
602
603        // "predefined case (kaksikymmentä, ...)": the 3-tuple overrides the
604        // singular nominative only.
605        let mut tmp_case = options.case;
606        if let Some(c) = case_override {
607            if options.case == NOM && !options.plural {
608                tmp_case = c;
609            }
610        }
611
612        let (sg, pl) = kotus_suffixes(kotus, tmp_case)?;
613        // Python indexes the (singular, plural) tuple with the bool directly.
614        let choices = if options.plural { pl } else { sg };
615
616        // "many choices, choose preferred or first": Python intersects the
617        // choice set with `options.prefer` and takes the single common element
618        // if and only if exactly one matches; otherwise choices[0].
619        let mut suffix = choices[0].to_string();
620        if choices.len() > 1 {
621            let common: Vec<&str> = choices
622                .iter()
623                .copied()
624                .filter(|c| options.prefer.iter().any(|p| p.as_str() == *c))
625                .collect();
626            if common.len() == 1 {
627                suffix = common[0].to_string();
628            }
629        }
630
631        // "apply vowel harmony" — gated on the *stem*'s characters, not the
632        // suffix's: only a stem with no back vowel at all fronts its suffix.
633        if !stem.chars().any(|ch| BACK_TO_FRONT.iter().any(|(b, _)| *b == ch)) {
634            for (back, front) in BACK_TO_FRONT {
635                suffix = suffix.replace(back, front);
636            }
637        }
638
639        out.push_str(stem);
640        out.push_str(&suffix);
641    }
642    Ok(out)
643}
644
645// ---------------------------------------------------------------------------
646// Table — the OrderedDict behind self.cards / self.ords
647// ---------------------------------------------------------------------------
648
649/// `BigInt -> Value`, in descending key order.
650///
651/// Python uses an `OrderedDict` and `splitnum` walks it in **insertion** order:
652/// high (10^600 … 10^12, 10^9, 10^6), then mid (1000, 100, 90 … 30), then low
653/// (20 … 0). That happens to be strictly descending — verified against the live
654/// table — so a descending `Vec` reproduces the iteration exactly and allows
655/// the same binary-search shortcut `base::Cards` uses.
656#[derive(Debug, Default)]
657struct Table {
658    entries: Vec<(BigInt, Value)>,
659}
660
661impl Table {
662    fn new() -> Self {
663        Table { entries: Vec::new() }
664    }
665
666    /// Append in Python's insertion order; callers must stay descending.
667    fn push(&mut self, key: BigInt, value: Value) {
668        self.entries.push((key, value));
669    }
670
671    fn get(&self, key: &BigInt) -> Option<&Value> {
672        self.entries
673            .binary_search_by(|(k, _)| key.cmp(k))
674            .ok()
675            .map(|i| &self.entries[i].1)
676    }
677
678    /// Entries from the first key `<= value`, descending — Python's
679    /// `for elem in elems: if elem > value: continue`.
680    fn iter_from(&self, value: &BigInt) -> impl Iterator<Item = &(BigInt, Value)> {
681        let start = self.entries.partition_point(|(k, _)| k > value);
682        self.entries[start..].iter()
683    }
684
685    fn highest(&self) -> Option<&BigInt> {
686        self.entries.first().map(|(k, _)| k)
687    }
688}
689
690// ---------------------------------------------------------------------------
691// splitnum tree
692// ---------------------------------------------------------------------------
693
694/// Same distinction as `base::Node`: `Leaf` is Python's `(value, num)` tuple,
695/// `List` a nested list. `clean` branches on it, so it must not be flattened.
696#[derive(Debug, Clone)]
697enum Node {
698    Leaf(Value, BigInt),
699    List(Vec<Node>),
700}
701
702impl Node {
703    fn is_leaf(&self) -> bool {
704        matches!(self, Node::Leaf(..))
705    }
706}
707
708// ---------------------------------------------------------------------------
709// High numword generation
710// ---------------------------------------------------------------------------
711
712/// Port of `Num2Word_EUR.gen_high_numwords` (duplicated rather than imported so
713/// this file stays self-contained and does not depend on another language's
714/// module).
715fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
716    let mut out: Vec<String> = Vec::new();
717    // Python: [u + t for t in tens for u in units] — tens is the outer loop.
718    for t in tens {
719        for u in units {
720            out.push(format!("{}{}", u, t));
721        }
722    }
723    out.reverse();
724
725    const REPLACEMENTS: &[(&str, &str)] = &[
726        ("novemn", "noven"),
727        ("novemo", "novo"),
728        ("octoo", "octo"),
729        ("quintd", "quind"),
730        ("quintn", "quin"),
731        ("quintq", "quinq"),
732        ("quints", "quins"),
733        ("quintt", "quint"),
734        ("quintv", "quinv"),
735        ("septenn", "septen"),
736        ("septent", "sept"),
737        ("sexn", "sen"),
738        ("sexs", "ses"),
739        ("tresd", "tred"),
740        ("tresn", "tren"),
741        ("tress", "tres"),
742        ("tresv", "trev"),
743        ("unno", "uno"),
744    ];
745    for (k, v) in REPLACEMENTS {
746        out = out.iter().map(|o| o.replace(k, v)).collect();
747    }
748    out.extend(lows.iter().map(|s| s.to_string()));
749    out
750}
751
752/// Python's `s[n:]` — by character, and empty (never a panic) when `s` is
753/// shorter than `n`.
754fn char_slice_from(s: &str, n: usize) -> String {
755    s.chars().skip(n).collect()
756}
757
758/// Python's `high[i + -10]`, including the negative-index wraparound that
759/// bug (2) in the module docs depends on.
760fn wrap_index(i: usize, len: usize) -> usize {
761    let idx = i as isize - 10;
762    (if idx < 0 { idx + len as isize } else { idx }) as usize
763}
764
765/// The stem-rewriting half of `Num2Word_FI.set_high_numwords`.
766///
767/// Reproduces the `octo`/`nove` look-back bugs verbatim; see the module docs.
768/// Diffed against the live Python table for all 100 stems.
769fn translate_high(high: &[String]) -> Vec<String> {
770    const REPLACEMENTS: [(&str, &str); 4] = [
771        ("qu", "kv"),
772        ("x", "ks"),
773        ("c", "k"),
774        ("kent", "sent"), // applied after c -> k to cent
775    ];
776
777    let mut translated: Vec<String> = Vec::with_capacity(high.len());
778    for (i, numword) in high.iter().enumerate() {
779        let mut numword: String = numword.clone();
780
781        // 1e6**6 is sekstiljoona but 1e6**16 is sedekiljoona.
782        if numword.starts_with("sex") && numword != "sext" {
783            // Blanket replace, not just the prefix: this is what turns
784            // "sexagint" into "seagint".
785            numword = numword.replace("sex", "se");
786        }
787        // 1e6**7 is septiljoona but 1e6**17 is septendekiljoona.
788        else if numword.starts_with("sept") && numword != "sept" {
789            numword = format!("septen{}", char_slice_from(&numword, "sept".len()));
790        }
791        // 1e6**8 is oktiljoona but 1e6**18 is duodevigintiljoona (2 from 20).
792        else if numword.starts_with("octo") {
793            // NB: indexes the *original* list, then slices "octo".len() off a
794            // word that generally does not start with "octo".
795            let w = &high[wrap_index(i, high.len())];
796            numword = format!("duode{}", char_slice_from(w, "octo".len()));
797        }
798        // 1e6**9 is noniljoona but 1e6**19 is undevigintiljoona (1 from 20).
799        else if numword.starts_with("nove") {
800            let w = &high[wrap_index(i, high.len())];
801            // len("nove") + 1 == 5 — one more than the prefix it claims to strip.
802            numword = format!("unde{}", char_slice_from(w, "nove".len() + 1));
803        }
804
805        // apply general replacements to all numwords
806        for (a, b) in REPLACEMENTS {
807            numword = numword.replace(a, b);
808        }
809        translated.push(numword);
810    }
811    translated
812}
813
814// ---------------------------------------------------------------------------
815// Currency tables
816// ---------------------------------------------------------------------------
817
818/// `Num2Word_FI.CURRENCY_FORMS`.
819///
820/// FI's own class-body dict — **not** the `Num2Word_EUR` one that
821/// `Num2Word_EN.__init__` mutates in place, because declaring the attribute on
822/// the subclass shadows it outright. See the module docs.
823///
824/// Built in the same three steps as the Python class body — the literal dict,
825/// then the `crowns` / `dollars` / `pounds` `for curr_code in ...` loops that
826/// write into it at class-definition time — so the entries line up one-for-one
827/// with `lang_FI.py`. Verified against the live table: 24 codes, every one a
828/// 2-form tuple on both sides.
829///
830/// (The loops also leave a stray `curr_code = "GBP"` class attribute behind,
831/// Python's for-loop variable outliving its scope. It is never read, so it has
832/// no counterpart here.)
833fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
834    // Module-level constants in lang_FI.py.
835    const GENERIC_CENTS: [&str; 2] = ["sentti", "senttiä"];
836    const GENERIC_CENTAVOS: [&str; 2] = ["centavo", "centavoa"];
837
838    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
839
840    // ---- the literal dict ----
841    m.insert("BRL", CurrencyForms::new(&["real", "realia"], &GENERIC_CENTAVOS));
842    m.insert("CHF", CurrencyForms::new(&["frangi", "frangia"], &["rappen", "rappenia"]));
843    m.insert("CNY", CurrencyForms::new(&["juan", "juania"], &["fen", "feniä"]));
844    m.insert("EUR", CurrencyForms::new(&["euro", "euroa"], &GENERIC_CENTS));
845    // historical
846    m.insert("FIM", CurrencyForms::new(&["markka", "markkaa"], &["penni", "penniä"]));
847    m.insert("INR", CurrencyForms::new(&["rupia", "rupiaa"], &["paisa", "paisaa"]));
848    // rare subunit
849    m.insert("JPY", CurrencyForms::new(&["jeni", "jeniä"], &["sen", "seniä"]));
850    // rare subunit
851    m.insert("KRW", CurrencyForms::new(&["won", "wonia"], &["jeon", "jeonia"]));
852    // rare subunit
853    m.insert("KPW", CurrencyForms::new(&["won", "wonia"], &["chon", "chonia"]));
854    m.insert("MXN", CurrencyForms::new(&["peso", "pesoa"], &GENERIC_CENTAVOS));
855    m.insert("RUB", CurrencyForms::new(&["rupla", "ruplaa"], &["kopeekka", "kopeekkaa"]));
856    m.insert("TRY", CurrencyForms::new(&["liira", "liiraa"], &["kuruş", "kuruşia"]));
857    m.insert("ZAR", CurrencyForms::new(&["randi", "randia"], &GENERIC_CENTS));
858
859    // ---- crowns ----
860    for code in ["DKK", "ISK", "NOK", "SEK"] {
861        m.insert(code, CurrencyForms::new(&["kruunu", "kruunua"], &["äyri", "äyriä"]));
862    }
863    // ---- dollars ----
864    for code in ["AUD", "CAD", "HKD", "NZD", "SGD", "USD"] {
865        m.insert(code, CurrencyForms::new(&["dollari", "dollaria"], &GENERIC_CENTS));
866    }
867    // ---- pounds ----
868    // Python loops here too (`for curr_code in ("GBP",)`), but over a 1-tuple;
869    // written as a plain insert since the loop carries no extra data.
870    m.insert("GBP", CurrencyForms::new(&["punta", "puntaa"], &["penny", "pennyä"]));
871
872    m
873}
874
875/// `Num2Word_FI.CURRENCY_ADJECTIVES`. Shadows EUR's dict, same as the forms.
876///
877/// Only ever consulted on the float path: FI's `to_currency` int branch never
878/// calls `prefix_currency` — see [`LangFi::to_currency`].
879fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
880    [
881        ("AUD", "Australian"),
882        ("BRL", "Brasilian"),
883        ("CAD", "Kanadan"),
884        ("CHF", "Sveitsin"),
885        ("DKK", "Tanskan"),
886        ("FIM", "Suomen"), // historical
887        ("GBP", "Englannin"),
888        ("HKD", "Hongkongin"),
889        ("INR", "Intian"),
890        ("ISK", "Islannin"),
891        ("KRW", "Etelä-Korean"),
892        ("KPW", "Pohjois-Korean"),
893        ("MXN", "Meksikon"),
894        ("NOK", "Norjan"),
895        ("NZD", "Uuden-Seelannin"),
896        ("RUB", "Venäjän"),
897        ("SEK", "Ruotsin"),
898        ("SGD", "Singaporen"),
899        ("TRY", "Turkin"),
900        ("USD", "Yhdysvaltain"),
901        ("ZAR", "Etelä-Afrikan"),
902    ]
903    .into_iter()
904    .collect()
905}
906
907// ---------------------------------------------------------------------------
908// The language
909// ---------------------------------------------------------------------------
910
911pub struct LangFi {
912    cards: Table,
913    ords: Table,
914    maxval: BigInt,
915    exclude_title: Vec<String>,
916    currency_forms: HashMap<&'static str, CurrencyForms>,
917    currency_adjectives: HashMap<&'static str, &'static str>,
918}
919
920impl Default for LangFi {
921    fn default() -> Self {
922        Self::new()
923    }
924}
925
926impl LangFi {
927    pub fn new() -> Self {
928        // ---- Num2Word_EUR.setup ----
929        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
930        let units = [
931            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
932        ];
933        let tens = [
934            "dec",
935            "vigint",
936            "trigint",
937            "quadragint",
938            "quinquagint",
939            "sexagint",
940            "septuagint",
941            "octogint",
942            "nonagint",
943        ];
944        let mut high = vec!["cent".to_string()];
945        high.extend(gen_high_numwords(&units, &tens, &lows));
946
947        let mut cards = Table::new();
948        let mut ords = Table::new();
949
950        // ---- Num2Word_FI.set_high_numwords ----
951        let translated = translate_high(&high);
952        let ten = BigInt::from(10u8);
953        // max = 6 * len(translated); zip(translated, range(max, 0, -6)).
954        // Both sides are 100 long, so every stem is consumed and n ends at 6.
955        let mut n: i64 = 6 * translated.len() as i64;
956        for word in translated.iter() {
957            if n <= 0 {
958                break; // range(max, 0, -6) is exhausted (unreachable: lengths match)
959            }
960            if n == 6 {
961                // "irregularity considering short scale and long scale" — 10^9
962                // is inserted *before* 10^6, keeping the table descending.
963                cards.push(ten.pow(9), vec![stem("miljard", 5)]);
964                ords.push(ten.pow(9), vec![stem("miljardi", 45)]);
965            }
966            cards.push(ten.pow(n as u32), vec![stem(&format!("{}iljoon", word), 10)]);
967            ords.push(ten.pow(n as u32), vec![stem(&format!("{}iljoona", word), 45)]);
968            n -= 6;
969        }
970
971        // ---- Num2Word_FI.setup: mid_numwords / mid_ords ----
972        cards.push(BigInt::from(1000), vec![stem("tuha", 46)]);
973        cards.push(BigInt::from(100), vec![stem("sa", 109)]);
974        cards.push(BigInt::from(90), vec![stem("yhdeks", 110), stem_case("kymmen", 132, PTV)]);
975        cards.push(BigInt::from(80), vec![stem("kahdeks", 110), stem_case("kymmen", 132, PTV)]);
976        cards.push(BigInt::from(70), vec![stem("seitsem", 110), stem_case("kymmen", 132, PTV)]);
977        cards.push(BigInt::from(60), vec![stem("kuu", 27), stem_case("kymmen", 132, PTV)]);
978        cards.push(BigInt::from(50), vec![stem("vii", 27), stem_case("kymmen", 132, PTV)]);
979        cards.push(BigInt::from(40), vec![stem("nelj", 10), stem_case("kymmen", 132, PTV)]);
980        cards.push(BigInt::from(30), vec![stem("kolm", 108), stem_case("kymmen", 132, PTV)]);
981
982        ords.push(BigInt::from(1000), vec![stem("tuhanne", 45)]);
983        ords.push(BigInt::from(100), vec![stem("sada", 45)]);
984        ords.push(BigInt::from(90), vec![stem("yhdeksä", 45), stem("kymmene", 45)]);
985        ords.push(BigInt::from(80), vec![stem("kahdeksa", 45), stem("kymmene", 45)]);
986        ords.push(BigInt::from(70), vec![stem("seitsemä", 45), stem("kymmene", 45)]);
987        ords.push(BigInt::from(60), vec![stem("kuude", 45), stem("kymmene", 45)]);
988        ords.push(BigInt::from(50), vec![stem("viide", 45), stem("kymmene", 45)]);
989        ords.push(BigInt::from(40), vec![stem("neljä", 45), stem("kymmene", 45)]);
990        ords.push(BigInt::from(30), vec![stem("kolma", 45), stem("kymmene", 45)]);
991
992        // ---- Num2Word_FI.setup: low_numwords / low_ords ----
993        cards.push(BigInt::from(20), vec![stem("ka", 31), stem_case("kymmen", 132, PTV)]);
994        cards.push(BigInt::from(19), vec![stem("yhdeks", 110), Part::Text("toista".into())]);
995        cards.push(BigInt::from(18), vec![stem("kahdeks", 110), Part::Text("toista".into())]);
996        cards.push(BigInt::from(17), vec![stem("seitsem", 110), Part::Text("toista".into())]);
997        cards.push(BigInt::from(16), vec![stem("kuu", 27), Part::Text("toista".into())]);
998        cards.push(BigInt::from(15), vec![stem("vii", 27), Part::Text("toista".into())]);
999        cards.push(BigInt::from(14), vec![stem("nelj", 10), Part::Text("toista".into())]);
1000        cards.push(BigInt::from(13), vec![stem("kolm", 108), Part::Text("toista".into())]);
1001        cards.push(BigInt::from(12), vec![stem("ka", 31), Part::Text("toista".into())]);
1002        cards.push(BigInt::from(11), vec![stem("y", 31), Part::Text("toista".into())]);
1003        cards.push(BigInt::from(10), vec![stem("kymmen", 132)]);
1004        cards.push(BigInt::from(9), vec![stem("yhdeks", 110)]);
1005        cards.push(BigInt::from(8), vec![stem("kahdeks", 110)]);
1006        cards.push(BigInt::from(7), vec![stem("seitsem", 110)]);
1007        cards.push(BigInt::from(6), vec![stem("kuu", 27)]);
1008        cards.push(BigInt::from(5), vec![stem("vii", 27)]);
1009        cards.push(BigInt::from(4), vec![stem("nelj", 10)]);
1010        cards.push(BigInt::from(3), vec![stem("kolm", 108)]);
1011        cards.push(BigInt::from(2), vec![stem("ka", 31)]);
1012        cards.push(BigInt::from(1), vec![stem("y", 31)]);
1013        cards.push(BigInt::zero(), vec![stem("noll", 10)]);
1014
1015        ords.push(BigInt::from(20), vec![stem("kahde", 45), stem("kymmene", 45)]);
1016        ords.push(BigInt::from(19), vec![stem("yhdeksä", 45), Part::Text("toista".into())]);
1017        ords.push(BigInt::from(18), vec![stem("kahdeksa", 45), Part::Text("toista".into())]);
1018        ords.push(BigInt::from(17), vec![stem("seitsemä", 45), Part::Text("toista".into())]);
1019        ords.push(BigInt::from(16), vec![stem("kuude", 45), Part::Text("toista".into())]);
1020        ords.push(BigInt::from(15), vec![stem("viide", 45), Part::Text("toista".into())]);
1021        ords.push(BigInt::from(14), vec![stem("neljä", 45), Part::Text("toista".into())]);
1022        ords.push(BigInt::from(13), vec![stem("kolma", 45), Part::Text("toista".into())]);
1023        ords.push(BigInt::from(12), vec![stem("kahde", 45), Part::Text("toista".into())]);
1024        ords.push(BigInt::from(11), vec![stem("yhde", 45), Part::Text("toista".into())]);
1025        ords.push(BigInt::from(10), vec![stem("kymmene", 45)]);
1026        ords.push(BigInt::from(9), vec![stem("yhdeksä", 45)]);
1027        ords.push(BigInt::from(8), vec![stem("kahdeksa", 45)]);
1028        ords.push(BigInt::from(7), vec![stem("seitsemä", 45)]);
1029        ords.push(BigInt::from(6), vec![stem("kuude", 45)]);
1030        ords.push(BigInt::from(5), vec![stem("viide", 45)]);
1031        ords.push(BigInt::from(4), vec![stem("neljä", 45)]);
1032        ords.push(BigInt::from(3), vec![stem("kolma", 45)]);
1033        ords.push(BigInt::from(2), vec![stem("toi", 38)]);
1034        ords.push(BigInt::from(1), vec![stem("ensimmäi", 38)]);
1035        ords.push(BigInt::zero(), vec![stem("nolla", 45)]);
1036
1037        // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10^600 = 10^603.
1038        // Computed from `cards` only, but `to_ordinal` checks against it too.
1039        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
1040
1041        LangFi {
1042            cards,
1043            ords,
1044            maxval,
1045            exclude_title: vec!["pilkku".into(), "miinus".into()],
1046            // Built once here, never per call: `to_currency` only ever reads
1047            // these, and rebuilding them on each call is what made an earlier
1048            // revision of this port slower than the Python it replaces.
1049            currency_forms: build_currency_forms(),
1050            currency_adjectives: build_currency_adjectives(),
1051        }
1052    }
1053
1054    /// Port of `Num2Word_FI.splitnum`.
1055    ///
1056    /// `None` is Python falling off the loop and implicitly returning `None`
1057    /// (value above every card) — impossible after the MAXVAL guard.
1058    fn splitnum(&self, value: &BigInt, options: &Options) -> Result<Option<Vec<Node>>> {
1059        let elems = if options.ordinal { &self.ords } else { &self.cards };
1060
1061        for (elem, word) in elems.iter_from(value) {
1062            let mut out: Vec<Node> = Vec::new();
1063            let (div, mod_) = if value.is_zero() {
1064                (BigInt::one(), BigInt::zero())
1065            } else {
1066                value.div_mod_floor(elem)
1067            };
1068
1069            if div.is_one() {
1070                let one = BigInt::one();
1071                // elems[1]; present in both tables.
1072                let v = elems.get(&one).cloned().ok_or_else(|| N2WError::Key("1".into()))?;
1073                out.push(Node::Leaf(v, one));
1074            } else {
1075                if &div == value {
1076                    // "The system tallies, eg Roman Numerals": Python would do
1077                    // `div * elems[elem]`, repeating a *tuple/list* of stems —
1078                    // which `inflect` could not read back. Unreachable for FI:
1079                    // reaching here needs elem == 1 with value >= 2, but cards
1080                    // 0..=20 are all present, so the first card <= value is
1081                    // always >= 2 once value >= 2.
1082                    return Err(N2WError::Type(
1083                        "lang_FI splitnum tally branch is unreachable".into(),
1084                    ));
1085                }
1086                match self.splitnum(&div, options)? {
1087                    Some(sub) => out.push(Node::List(sub)),
1088                    None => return Ok(None),
1089                }
1090            }
1091
1092            out.push(Node::Leaf(word.clone(), elem.clone()));
1093
1094            if !mod_.is_zero() {
1095                match self.splitnum(&mod_, options)? {
1096                    Some(sub) => out.push(Node::List(sub)),
1097                    None => return Ok(None),
1098                }
1099            }
1100
1101            return Ok(Some(out));
1102        }
1103        Ok(None)
1104    }
1105
1106    /// Port of `Num2Word_FI.merge`.
1107    ///
1108    /// <http://www.kielitoimistonohjepankki.fi/ohje/49>
1109    ///
1110    /// `None` mirrors Python falling off the end of the if/elif chain when
1111    /// `lnum == rnum` (and `lnum != 1`) — there is no `else`, so it returns
1112    /// `None` and `to_cardinal`'s `words, num = self.clean(...)` then dies with
1113    /// `TypeError: cannot unpack non-sequence NoneType`. Unreachable with FI's
1114    /// card set: `div == elem` would require `elem**2 <= value < elem**2+elem`
1115    /// while `elem` is also the largest card `<= value`, which no gap in
1116    /// {0..20, 30..90 by 10, 100, 1000, 10^6, 10^9, 10^12 …} allows.
1117    ///
1118    /// Named `merge_parts`, not `merge`, purely to keep it clear of the
1119    /// `Lang::merge` trait method it would otherwise shadow — that one takes
1120    /// `&str` and is never reached for FI.
1121    fn merge_parts(
1122        &self,
1123        l: (&Value, &BigInt),
1124        r: (&Value, &BigInt),
1125        options: &Options,
1126    ) -> Result<Option<(Value, BigInt)>> {
1127        let (ltext, lnum) = l;
1128        let (rtext, rnum) = r;
1129
1130        let one = BigInt::one();
1131        let two = BigInt::from(2);
1132        let thousand = BigInt::from(1000);
1133
1134        // "ignore lpair if lnum is 1"
1135        if *lnum == one {
1136            let rt = inflect(rtext, options)?;
1137            return Ok(Some((text(rt), rnum.clone())));
1138        }
1139        // "rnum is added to lnum"
1140        if lnum > rnum {
1141            let lt = inflect(ltext, options)?;
1142            let rt = inflect(rtext, options)?;
1143            // "separate groups with space"
1144            let fmt = if *lnum >= thousand { " " } else { "" };
1145            return Ok(Some((text(format!("{}{}{}", lt, fmt, rt)), lnum + rnum)));
1146        }
1147        // "rnum is multiplied by lnum"
1148        if lnum < rnum {
1149            let (lval, rt): (Value, String) = if options.ordinal {
1150                // "kahdessadas, not toinensadas"
1151                let lval = if *lnum == two { vec![stem("kahde", 45)] } else { ltext.clone() };
1152                (lval, inflect(rtext, options)?)
1153            } else {
1154                // "kaksituhatta but kahdettuhannet"
1155                let mut rcase = options.case;
1156                if options.case == NOM && !options.plural {
1157                    rcase = PTV;
1158                }
1159                (ltext.clone(), inflect(rtext, &options.with_case(rcase))?)
1160            };
1161            let lt = inflect(&lval, options)?;
1162            // NB: the multiplication arm never takes the space `fmt`; only the
1163            // addition arm above rebinds it.
1164            return Ok(Some((text(format!("{}{}", lt, rt)), lnum * rnum)));
1165        }
1166
1167        Ok(None)
1168    }
1169
1170    /// Port of `Num2Word_FI.clean` (identical in structure to `base.clean`,
1171    /// including the quirk where the tail `val[2:]` is appended as a *nested
1172    /// list* rather than spliced).
1173    fn clean(&self, val: Vec<Node>, options: &Options) -> Result<Node> {
1174        let mut val = val;
1175        while val.len() != 1 {
1176            let mut out: Vec<Node> = Vec::new();
1177            if val.len() >= 2 && val[0].is_leaf() && val[1].is_leaf() {
1178                let (lt, ln) = match &val[0] {
1179                    Node::Leaf(t, n) => (t.clone(), n.clone()),
1180                    _ => unreachable!(),
1181                };
1182                let (rt, rn) = match &val[1] {
1183                    Node::Leaf(t, n) => (t.clone(), n.clone()),
1184                    _ => unreachable!(),
1185                };
1186                match self.merge_parts((&lt, &ln), (&rt, &rn), options)? {
1187                    Some((mt, mn)) => out.push(Node::Leaf(mt, mn)),
1188                    None => {
1189                        return Err(N2WError::Type(
1190                            "cannot unpack non-sequence NoneType object".into(),
1191                        ))
1192                    }
1193                }
1194                if val.len() > 2 {
1195                    out.push(Node::List(val[2..].to_vec()));
1196                }
1197            } else {
1198                for elem in val.into_iter() {
1199                    match elem {
1200                        Node::List(inner) => {
1201                            if inner.len() == 1 {
1202                                out.push(inner.into_iter().next().unwrap());
1203                            } else {
1204                                out.push(self.clean(inner, options)?);
1205                            }
1206                        }
1207                        leaf => out.push(leaf),
1208                    }
1209                }
1210            }
1211            val = out;
1212        }
1213        Ok(val.into_iter().next().unwrap())
1214    }
1215
1216    /// Drives splitnum + clean for an already-non-negative, in-range value.
1217    fn convert(&self, value: &BigInt, options: &Options) -> Result<String> {
1218        let tree = self.splitnum(value, options)?.ok_or_else(|| {
1219            N2WError::Overflow(format!("abs({}) must be less than {}.", value, self.maxval))
1220        })?;
1221        match self.clean(tree, options)? {
1222            Node::Leaf(v, _) => inflect(&v, options),
1223            Node::List(_) => Err(N2WError::Type("clean did not reduce".into())),
1224        }
1225    }
1226
1227    /// Port of `Num2Word_Base.verify_ordinal`. The float arm cannot fire on a
1228    /// `BigInt`; the negative arm is the one the corpus exercises.
1229    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
1230        if value.is_negative() {
1231            return Err(N2WError::Type(format!(
1232                "Cannot treat negative num {} as ordinal.",
1233                value
1234            )));
1235        }
1236        Ok(())
1237    }
1238
1239    /// The body of `Num2Word_FI.to_cardinal` past the `NAME_TO_CASE` lookup
1240    /// and the int/float routing, for an integral value with an arbitrary
1241    /// `Options`. `to_cardinal` and `to_cardinal_kw` both land here.
1242    fn to_cardinal_opts(&self, value: &BigInt, options: &Options) -> Result<String> {
1243        // `out = self.negword` — raw, see `to_cardinal`'s doc.
1244        let mut out = String::new();
1245        let mut v = value.clone();
1246        if v.is_negative() {
1247            v = v.abs();
1248            out.push_str(self.negword());
1249        }
1250
1251        if v >= self.maxval {
1252            return Err(N2WError::Overflow(format!(
1253                "abs({}) must be less than {}.",
1254                v, self.maxval
1255            )));
1256        }
1257
1258        let words = self.convert(&v, options)?;
1259        Ok(self.title(&format!("{}{}", out, words)))
1260    }
1261
1262    /// The body of `Num2Word_FI.to_ordinal` past the `NAME_TO_CASE` lookup.
1263    /// Note Python resolves `case` *before* `verify_ordinal`, so callers must
1264    /// build `Options` (and surface its KeyError) first.
1265    fn to_ordinal_opts(&self, value: &BigInt, options: &Options) -> Result<String> {
1266        self.verify_ordinal(value)?;
1267        if value >= &self.maxval {
1268            return Err(N2WError::Overflow(format!(
1269                "abs({}) must be less than {}.",
1270                value, self.maxval
1271            )));
1272        }
1273
1274        let words = self.convert(value, options)?;
1275        Ok(self.title(&words))
1276    }
1277
1278    /// Builds `Options` from a kwargs bag, in Python's evaluation order:
1279    /// `case = NAME_TO_CASE[case]` first (KeyError on unknown or non-string
1280    /// keys), then `plural`/`prefer` verbatim.
1281    ///
1282    /// Values our port cannot model faithfully (e.g. `plural=2`, whose Python
1283    /// behaviour mixes tuple-indexing with truthiness) return NotImplemented
1284    /// so the dispatcher falls back to Python. The corpus only exercises
1285    /// bools, case names and string lists.
1286    fn options_from_kwargs(&self, kw: &Kwargs, ordinal: bool) -> Result<Options> {
1287        let case = match kw.get("case") {
1288            None => NOM,
1289            Some(KwVal::Str(s)) => name_to_case(s)?,
1290            // NAME_TO_CASE[None] / NAME_TO_CASE[5] / ... — a dict miss on the
1291            // non-string key. Only the exception type is oracle-checked.
1292            Some(other) => return Err(N2WError::Key(format!("{:?}", other))),
1293        };
1294        let plural = match kw.get("plural") {
1295            None => false,
1296            Some(KwVal::Bool(b)) => *b,
1297            // Python indexes `(sg, pl)[plural]`, so ints 0/1 behave as bools.
1298            Some(KwVal::Int(0)) => false,
1299            Some(KwVal::Int(1)) => true,
1300            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1301        };
1302        let prefer = match kw.get("prefer") {
1303            // `options.prefer or set()` — None is the empty preference set.
1304            None | Some(KwVal::None) => Vec::new(),
1305            Some(KwVal::List(l)) => l.clone(),
1306            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1307        };
1308        Ok(Options { ordinal, case, plural, prefer })
1309    }
1310}
1311
1312/// Best-effort Python `str()` of a float/Decimal for TypeError messages
1313/// (`errmsg_floatord` / `errmsg_negord`). The corpus records only the
1314/// exception *type*; the message follows base.py's format strings.
1315fn float_msg_repr(value: &FloatValue) -> String {
1316    match value {
1317        FloatValue::Float { value: f, .. } => {
1318            if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e16 {
1319                format!("{:.1}", f)
1320            } else {
1321                format!("{}", f)
1322            }
1323        }
1324        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
1325    }
1326}
1327
1328impl Lang for LangFi {
1329
1330    fn python_maxval(&self) -> Option<num_bigint::BigInt> {
1331        // Python class attribute MAXVAL (self-contained converter).
1332        Some(num_bigint::BigInt::from(10u32).pow(603))
1333    }
1334    /// This language's own `to_currency(currency=...)` default,
1335    /// read from the live Python signature. Only 44 of 156 use EUR.
1336    fn default_currency(&self) -> &str {
1337        "EUR"
1338    }
1339
1340    /// This language's own `to_currency(separator=...)` default,
1341    /// read from the live Python signature. Base's is ",", but only
1342    /// 36 of 149 languages actually use it — most default to " " or a
1343    /// conjunction, so inheriting Base's comma silently corrupts them.
1344    fn default_separator(&self) -> &str {
1345        " ja"
1346    }
1347
1348    // `cards()` / `maxval()` / `merge()` intentionally stay at their trait
1349    // defaults: FI's tables are not `base::Cards` and its `merge` needs the
1350    // structured value plus an `Options`, so the shared engine is bypassed
1351    // entirely. See the module docs.
1352
1353    fn negword(&self) -> &str {
1354        "miinus "
1355    }
1356
1357    fn pointword(&self) -> &str {
1358        "pilkku"
1359    }
1360
1361    fn exclude_title(&self) -> &[String] {
1362        &self.exclude_title
1363    }
1364
1365    /// Port of `Num2Word_FI.to_cardinal` for integral input.
1366    ///
1367    /// Note FI concatenates `self.negword` **raw** (`out = self.negword`),
1368    /// unlike `Num2Word_Base.to_cardinal`, which rebuilds it as
1369    /// `"%s " % self.negword.strip()`. Both yield "miinus " here, so the
1370    /// difference is invisible — but it is the reason this is written out
1371    /// rather than delegated to `base::default_to_cardinal`.
1372    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1373        // `is_title` is never set, so `title` inside is a no-op — kept for
1374        // fidelity.
1375        let options = Options { ordinal: false, case: NOM, plural: false, prefer: Vec::new() };
1376        self.to_cardinal_opts(value, &options)
1377    }
1378
1379    /// Port of `Num2Word_FI.to_ordinal`.
1380    ///
1381    /// Re-runs the whole engine against `self.ords`; it does **not**
1382    /// post-process the cardinal. Negatives raise `TypeError` via
1383    /// `verify_ordinal` before anything else happens.
1384    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1385        let options = Options { ordinal: true, case: NOM, plural: false, prefer: Vec::new() };
1386        self.to_ordinal_opts(value, &options)
1387    }
1388
1389    /// Port of `Num2Word_FI.to_ordinal_num`: `str(value) + "."`.
1390    ///
1391    /// No `verify_ordinal`, no MAXVAL check — so unlike `to_ordinal` it happily
1392    /// accepts negatives: `to_ordinal_num(-1)` == "-1.".
1393    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1394        Ok(format!("{}.", value))
1395    }
1396
1397    /// Port of `Num2Word_FI.to_year` with `suffix=None`.
1398    ///
1399    /// `self.to_cardinal(val).replace(" ", "") + suffix`: every space inside the
1400    /// cardinal is squeezed out (so 1005 → "tuhatviisi", not "tuhat viisi"),
1401    /// but the BC suffix keeps its own leading space because it is appended
1402    /// afterwards. The sign is stripped before `to_cardinal`, so a BC year
1403    /// never gets "miinus".
1404    fn to_year(&self, value: &BigInt) -> Result<String> {
1405        let mut val = value.clone();
1406        // suffix = suffix or "" -> "", which is falsy, hence the BC default.
1407        let mut suffix = "";
1408        if val.is_negative() {
1409            val = val.abs();
1410            suffix = " ennen ajanlaskun alkua";
1411        }
1412        Ok(format!("{}{}", self.to_cardinal(&val)?.replace(' ', ""), suffix))
1413    }
1414
1415    // ---- float/Decimal entry routing --------------------------------------
1416    //
1417    // `cardinal_float_entry` stays at the trait default: FI's
1418    // `assert int(value) == value` routing *is* base semantics (whole ->
1419    // integer engine, fractional -> to_cardinal_float), verified against the
1420    // frozen corpus. Only ordinal/ordinal_num/year need overrides.
1421
1422    /// `Num2Word_FI.to_ordinal(float/Decimal)` — `Num2Word_Base.verify_ordinal`
1423    /// runs first (after the NAME_TO_CASE lookup, which the no-kwargs entry
1424    /// cannot fail):
1425    ///
1426    ///   * fractional (`not value == int(value)`) → TypeError errmsg_floatord —
1427    ///     checked **before** the sign, so `to_ordinal(-1.5)` is the floatord
1428    ///     message;
1429    ///   * negative (`not abs(value) == value`) → TypeError errmsg_negord.
1430    ///     `abs(-0.0) == -0.0` is true, so **-0.0 passes** and yields "nollas";
1431    ///   * whole non-negative → the ordinal engine, same words as the int path
1432    ///     (`to_ordinal(5.0)` == "viides", `to_ordinal(Decimal("1E+2"))` ==
1433    ///     "sadas").
1434    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1435        match value.as_whole_int() {
1436            None => Err(N2WError::Type(format!(
1437                "Cannot treat float {} as ordinal.",
1438                float_msg_repr(value)
1439            ))),
1440            Some(i) => {
1441                if i.is_negative() {
1442                    return Err(N2WError::Type(format!(
1443                        "Cannot treat negative num {} as ordinal.",
1444                        float_msg_repr(value)
1445                    )));
1446                }
1447                self.to_ordinal(&i)
1448            }
1449        }
1450    }
1451
1452    /// `Num2Word_FI.to_ordinal_num(float/Decimal)`: `str(value) + "."`, no
1453    /// validation whatsoever — negatives and fractions included
1454    /// (`to_ordinal_num(-1.5)` == "-1.5.", `to_ordinal_num(1e+16)` == "1e+16.").
1455    /// `repr_str` is Python's `str(value)` as computed by the binding.
1456    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
1457        Ok(format!("{}.", repr_str))
1458    }
1459
1460    /// `Num2Word_FI.to_year(float/Decimal)`. `val < 0` is a *value* test —
1461    /// false for -0.0 (unlike `FloatValue::is_negative`, which reads the sign
1462    /// bit), so `to_year(-0.0)` == "nolla" with no BC suffix. The cardinal
1463    /// (int engine for whole values, decimal grammar for fractional) then has
1464    /// every space squeezed out, and the BC suffix keeps its own leading
1465    /// space: `to_year(-1.5)` == "yksipilkkuviisi ennen ajanlaskun alkua".
1466    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1467        let (below_zero, abs) = match value {
1468            FloatValue::Float { value: f, precision } => (
1469                *f < 0.0,
1470                FloatValue::Float { value: f.abs(), precision: *precision },
1471            ),
1472            FloatValue::Decimal { value: d, precision } => (
1473                d.is_negative(),
1474                FloatValue::Decimal { value: d.abs(), precision: *precision },
1475            ),
1476        };
1477        let (val, suffix) = if below_zero {
1478            (&abs, " ennen ajanlaskun alkua")
1479        } else {
1480            (value, "")
1481        };
1482        let cardinal = self.cardinal_float_entry(val, None)?;
1483        Ok(format!("{}{}", cardinal.replace(' ', ""), suffix))
1484    }
1485
1486    // ---- grammatical kwargs ------------------------------------------------
1487
1488    /// `to_cardinal(value, case="nominative", plural=False, prefer=None)`.
1489    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1490        if !kw.only(&["case", "plural", "prefer"]) {
1491            return Err(N2WError::Fallback("kwargs".into()));
1492        }
1493        let options = self.options_from_kwargs(kw, false)?;
1494        self.to_cardinal_opts(value, &options)
1495    }
1496
1497    /// `to_ordinal(value, case="nominative", plural=False, prefer=None)`.
1498    ///
1499    /// `NAME_TO_CASE[case]` runs before `verify_ordinal`, so
1500    /// `to_ordinal(-5, case="bogus")` is **KeyError**, not TypeError —
1501    /// `options_from_kwargs` resolves the case first.
1502    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1503        if !kw.only(&["case", "plural", "prefer"]) {
1504            return Err(N2WError::Fallback("kwargs".into()));
1505        }
1506        let options = self.options_from_kwargs(kw, true)?;
1507        self.to_ordinal_opts(value, &options)
1508    }
1509
1510    /// `to_ordinal_num(value, case="nominative", plural=False)`: the body never
1511    /// reads either kwarg (no NAME_TO_CASE lookup!), so `case="bogus"` is
1512    /// accepted and the result is still `str(value) + "."`.
1513    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1514        if !kw.only(&["case", "plural"]) {
1515            return Err(N2WError::Fallback("kwargs".into()));
1516        }
1517        Ok(format!("{}.", value))
1518    }
1519
1520    /// `to_year(val, suffix=None, longval=True)`. `longval` is never read.
1521    ///
1522    /// `suffix = suffix or ""` first, then — negatives only — `suffix = suffix
1523    /// or " ennen ajanlaskun alkua"`: a caller-supplied *truthy* suffix wins
1524    /// over the BC default, an empty/None one falls through to it.
1525    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1526        if !kw.only(&["suffix", "longval"]) {
1527            return Err(N2WError::Fallback("kwargs".into()));
1528        }
1529        let mut suffix: String = match kw.get("suffix") {
1530            None | Some(KwVal::None) => String::new(),
1531            Some(KwVal::Str(s)) => s.clone(),
1532            // A non-string suffix would concatenate onto str in Python
1533            // (TypeError) only after conversion; fall back to Python.
1534            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1535        };
1536        let mut val = value.clone();
1537        if val.is_negative() {
1538            val = val.abs();
1539            if suffix.is_empty() {
1540                suffix = " ennen ajanlaskun alkua".to_string();
1541            }
1542        }
1543        Ok(format!("{}{}", self.to_cardinal(&val)?.replace(' ', ""), suffix))
1544    }
1545
1546    /// `to_cardinal(float/Decimal, case=..., plural=..., prefer=...)`.
1547    ///
1548    /// Python resolves `case` first (KeyError on a bad name, even for
1549    /// fractional input), then routes: whole → the integer engine *with* the
1550    /// options; fractional → NotImplementedError unless the case is
1551    /// nominative, in which case `to_cardinal_float` runs and `plural`/
1552    /// `prefer` are silently dropped.
1553    fn to_cardinal_float_kw(
1554        &self,
1555        value: &FloatValue,
1556        precision_override: Option<u32>,
1557        kw: &Kwargs,
1558    ) -> Result<String> {
1559        if !kw.only(&["case", "plural", "prefer"]) {
1560            return Err(N2WError::Fallback("kwargs".into()));
1561        }
1562        let options = self.options_from_kwargs(kw, false)?;
1563        if let Some(i) = value.as_whole_int() {
1564            return self.to_cardinal_opts(&i, &options);
1565        }
1566        if options.case != NOM {
1567            // Python: raise NotImplementedError(...). NotImplemented also
1568            // makes the live binding fall back to Python, which raises the
1569            // very same exception — either route yields NotImplementedError.
1570            return Err(N2WError::NotImplemented(
1571                "Cases other than nominative are not implemented for \
1572                 cardinal floating point numbers."
1573                    .into(),
1574            ));
1575        }
1576        self.to_cardinal_float(value, precision_override)
1577    }
1578
1579    // ---- currency -------------------------------------------------------
1580    //
1581    // `currency_precision` stays at the trait default (100 for every code):
1582    // FI defines no `CURRENCY_PRECISION`, so `Num2Word_Base`'s empty dict and
1583    // its `.get(code, 100)` are exactly that. `to_cheque`, `money_verbose`,
1584    // `cents_verbose` and `cents_terse` are inherited from `Num2Word_Base`
1585    // unchanged, and the trait defaults already mirror them.
1586
1587    fn lang_name(&self) -> &str {
1588        "Num2Word_FI"
1589    }
1590
1591    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1592        self.currency_forms.get(code)
1593    }
1594
1595    fn currency_adjective(&self, code: &str) -> Option<&str> {
1596        self.currency_adjectives.get(code).copied()
1597    }
1598
1599    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1600    ///
1601    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
1602    /// raises IndexError. Every entry in FI's table has exactly two forms, so
1603    /// this is unreachable — but it is mapped to `Index` rather than panicking
1604    /// so the exception type survives if the table ever changes.
1605    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1606        let form = if n.is_one() { 0 } else { 1 };
1607        forms
1608            .get(form)
1609            .cloned()
1610            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1611    }
1612
1613    /// Port of `Num2Word_FI.to_currency`.
1614    ///
1615    /// FI intercepts **integers only** and hand-rolls the whole string; every
1616    /// other value is handed straight to `Num2Word_Base.to_currency` via
1617    /// `super()`. The int branch is not a re-spelling of Base's — it differs in
1618    /// three observable ways, all reproduced here:
1619    ///
1620    /// 1. **The double space.** Base builds its sign as
1621    ///    `"%s " % self.negword.strip()`; FI concatenates `self.negword` *raw*
1622    ///    into `"%s %s %s"`. `negword` is `"miinus "` — already trailing a
1623    ///    space — so the format's own separator lands on top of it and
1624    ///    `to_currency(-2, "EUR")` is **`"miinus  kaksi euroa"`**, with two
1625    ///    spaces. The trailing `.strip()` only touches the ends, so the seam
1626    ///    survives. Floats route to Base and get a single space, meaning
1627    ///    `-2` and `-2.0` disagree about their own spacing.
1628    /// 2. **`adjective` is ignored.** The int branch never calls
1629    ///    `prefix_currency`, so `to_currency(2, "USD", adjective=True)` is
1630    ///    `"kaksi dollaria"` while `to_currency(2.0, "USD", adjective=True)` is
1631    ///    `"kaksi Yhdysvaltain dollaria ja nolla senttiä"`.
1632    /// 3. **`pluralize` is bypassed** for an inline `cr1[0]`/`cr1[1]` pick.
1633    ///    Same result as `Num2Word_EUR.pluralize` for FI's uniformly 2-form
1634    ///    table, but it is the literal code path.
1635    ///
1636    /// `cents` and `separator` are simply unused by the int branch — Python
1637    /// accepts and drops them, since an int never renders a cents segment.
1638    ///
1639    /// A missing code raises `KeyError` on `self.CURRENCY_FORMS[currency]`,
1640    /// which the `except (KeyError, AttributeError)` turns into a `super()`
1641    /// call — and *that* is what raises the NotImplementedError, carrying
1642    /// `self.__class__.__name__` == `"Num2Word_FI"`. So the fallback is a real
1643    /// delegation, not a shortcut to the error: routing it through
1644    /// `default_to_currency` keeps the message's origin honest.
1645    fn to_currency(
1646        &self,
1647        val: &CurrencyValue,
1648        currency: &str,
1649        cents: bool,
1650        separator: Option<&str>,
1651        adjective: bool,
1652    ) -> Result<String> {
1653        // `separator=" ja"` is FI's own signature default; the trait resolves
1654        // `None` through `default_separator()` for us.
1655        let separator = separator.unwrap_or(self.default_separator());
1656
1657        // "Handle integers specially - just add currency name without cents".
1658        // Note this is `isinstance(val, int)`, so a whole *float* like 1.0 does
1659        // NOT come here — it keeps its cents segment via Base.
1660        if let CurrencyValue::Int(v) = val {
1661            let cr1 = match self.currency_forms.get(currency) {
1662                Some(forms) => &forms.unit,
1663                // KeyError -> "Fallback to base implementation for unknown
1664                // currency", which is where NotImplementedError comes from.
1665                None => {
1666                    return default_to_currency(self, val, currency, cents, separator, adjective)
1667                }
1668            };
1669
1670            // `minus_str = self.negword if val < 0 else ""` — raw, unstripped.
1671            let minus_str = if v.is_negative() { self.negword() } else { "" };
1672            let abs_val = v.abs();
1673            let money_str = self.to_cardinal(&abs_val)?;
1674
1675            // `cr1[0]` if abs_val == 1, else `cr1[1] if len(cr1) > 1 else cr1[0]`.
1676            let currency_str = if abs_val.is_one() {
1677                cr1.first()
1678            } else if cr1.len() > 1 {
1679                cr1.get(1)
1680            } else {
1681                cr1.first()
1682            }
1683            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
1684
1685            // ("%s %s %s" % (...)).strip() — see quirk (1) above.
1686            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1687                .trim()
1688                .to_string());
1689        }
1690
1691        // "For floats, use the parent class implementation".
1692        default_to_currency(self, val, currency, cents, separator, adjective)
1693    }
1694}