#!/usr/bin/env php
<?php
// =============================================================================
// property_tool — the Real estate and rentals tool's work, for the tool presets (tools_builtins/property.json). A command-line program, never reachable over the web.
//
//   php property_tool rentroll ROLL OUTPUT [today=2026-03-01 soon=7 within=60]   a rent roll CSV: overdue and upcoming rent, leases ending soon or already ended, deposit totals
//   php property_tool calc     PARAMS OUTPUT [price= rent= ...]                  yield, cash flow and mortgage payment from the numbers YOU give
//   php property_tool listing  TEXT OUTPUT                                       a listing text: missing required facts (area, rooms, price, availability) and claims nobody can verify
//
// ROLL is a CSV (comma, semicolon or tab; UTF-8 or Windows-1252/1255) with a header row. Columns are found by name in several languages: unit (apartment, property, flat, unidad, unité, Wohnung ...), tenant,
// rent (monthly), due (the date the rent is or was due), paid (a date, yes/no, or the amount received), lease end, deposit, currency. One row per rent due is fine: the same unit may appear many times.
// PARAMS is a text file of key=value lines (the extra arguments win): price, rent (per month), down (an amount, or 20% of the price), rate (yearly %), years, closing (one-off costs), tax and insurance and
// maintenance and other (per year), vacancy and management (% of rent). Nothing is assumed: a figure you leave out is left out and the result says so. All of it is arithmetic on YOUR numbers: not legal,
// financial, tax or investment advice; local law decides what a lease, a deposit or a listing must contain. The original is never changed.
// =============================================================================
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
function fail(string $m, int $rc = 1): void { fwrite(STDERR, "property_tool: $m\n"); exit($rc); }
[$sub, $in, $out] = [$argv[1] ?? '', $argv[2] ?? '', $argv[3] ?? ''];
$args = array_slice($argv, 4);
if (!in_array($sub, ['rentroll', 'calc', 'listing'], true) || $in === '' || $out === '') fail('usage: property_tool rentroll|calc|listing INPUT OUTPUT [ARGS]', 2);
$opt = []; foreach ($args as $a) foreach (preg_split('/\s+(?=\w+=)/', trim($a)) as $p) if (strpos($p, '=') !== false) { [$k, $v] = explode('=', $p, 2); $opt[strtolower(trim($k))] = trim($v); }
function read_csv(string $f): array {
    if (!is_file($f)) fail("no such file: $f");
    $t = (string)file_get_contents($f); if (strncmp($t, "\xEF\xBB\xBF", 3) === 0) $t = substr($t, 3);
    if (!mb_check_encoding($t, 'UTF-8')) { $c = preg_match('/[\xE0-\xFA]{3,}/', $t) ? 'CP1255' : 'CP1252'; $t = mb_convert_encoding($t, 'UTF-8', $c); }
    $t = str_replace(["\r\n", "\r"], "\n", $t); $first = strtok($t, "\n"); $c = ["\t" => substr_count($first, "\t"), ';' => substr_count($first, ';'), ',' => substr_count($first, ',')]; arsort($c); $sep = array_key_first($c);
    $rows = []; $h = fopen('php://memory', 'r+'); fwrite($h, $t); rewind($h); $ln = 0; $header = null;
    while (($r = fgetcsv($h, 0, $sep, '"', '')) !== false) { $ln++; if ($r === [null] || (count($r) === 1 && trim((string)$r[0]) === '')) continue; if ($header === null) { $header = array_map(fn($x) => trim((string)$x), $r); continue; } $rows[] = ['line' => $ln, 'cells' => $r]; }
    fclose($h); if ($header === null) fail('the file is empty'); return [$header, $rows];
}
function find_col(array $header, array $names): ?int { foreach ($header as $i => $h) { $x = mb_strtolower(trim(preg_replace('/[\s_\-.]+/u', ' ', $h))); $words = explode(' ', $x); foreach ($names as $n) if ($x === $n || (strpos($n, ' ') === false && in_array($n, $words, true)) || (mb_strlen($n) > 4 && mb_strpos($x, $n) !== false) || (strpos($n, ' ') !== false && mb_strpos($x, $n) !== false)) return $i; } return null; }
function parse_num(?string $s): ?float {
    $s = trim((string)$s); if ($s === '') return null; $neg = false;
    if (preg_match('/^\((.+)\)$/', $s, $m)) { $neg = true; $s = $m[1]; } if (substr($s, -1) === '-') { $neg = true; $s = substr($s, 0, -1); } if (substr($s, 0, 1) === '-' || substr($s, 0, 1) === '−') { $neg = !$neg; $s = substr($s, 1); }
    $s = preg_replace('/[^\d.,]/u', '', $s); if ($s === '' || !preg_match('/\d/', $s)) return null;
    $lc = strrpos($s, ','); $ld = strrpos($s, '.');
    if ($lc !== false && $ld !== false) { if ($lc > $ld) $s = str_replace('.', '', $s); else $s = str_replace(',', '', $s); $s = str_replace(',', '.', $s); }
    elseif ($lc !== false) { if (strlen($s) - $lc - 1 <= 2 && substr_count($s, ',') === 1) $s = str_replace(',', '.', $s); else $s = str_replace(',', '', $s); }
    elseif ($ld !== false && substr_count($s, '.') > 1) $s = str_replace('.', '', $s);
    $v = (float)$s; return $neg ? -$v : $v;
}
/** a date in Y-m-d, or null; $dmy tells how to read ambiguous d/m/y */
function parse_date(?string $s, bool $dmy = true): ?string {
    $s = trim((string)$s); if ($s === '') return null;
    if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})/', $s, $m)) return checkdate((int)$m[2], (int)$m[3], (int)$m[1]) ? sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]) : null;
    if (preg_match('/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})/', $s, $m)) { [$a, $b, $y] = [(int)$m[1], (int)$m[2], (int)$m[3]]; if ($y < 100) $y += 2000; [$d, $mo] = $dmy ? [$a, $b] : [$b, $a]; if (!checkdate($mo, $d, $y)) { [$d, $mo] = [$mo, $d]; if (!checkdate($mo, $d, $y)) return null; } return sprintf('%04d-%02d-%02d', $y, $mo, $d); }
    $t = strtotime($s); return $t === false ? null : date('Y-m-d', $t);
}
function dmy_guess(array $rows, ?int $c): bool { if ($c === null) return true; $a1 = 0; $a2 = 0; foreach ($rows as $r) if (preg_match('/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-]\d{2,4}/', trim($r['cells'][$c] ?? ''), $m)) { if ((int)$m[1] > 12) $a1++; if ((int)$m[2] > 12) $a2++; } return !($a2 > 0 && $a1 === 0); }
function money(float $v): string { return number_format($v, 2, '.', ','); }

const DISCL = "Arithmetic and text checks on what you supplied: not legal, financial, tax or investment advice. Local law decides what a lease, a deposit or a listing must contain; ask a qualified professional.";
function text_file(string $f): string { if (!is_file($f)) fail("no such file: $f"); $t = (string)file_get_contents($f); if (strncmp($t, "\xEF\xBB\xBF", 3) === 0) $t = substr($t, 3); if (!mb_check_encoding($t, 'UTF-8')) fail('the file is not UTF-8 text: ' . basename($f)); if (trim($t) === '') fail('the file is empty: ' . basename($f)); return $t; }
$name = basename($in); $msgs = [];

if ($sub === 'rentroll') {
    [$H, $R] = read_csv($in); if (!$R) fail('the rent roll has no rows under the header');
    $HX = array_map(fn($h) => preg_match('/due|paid|end|expir|deposit|fin|ende|scad|venc/iu', $h) ? '' : $h, $H);
    $cU = find_col($HX, ['unit', 'apartment', 'flat', 'property', 'address', 'unidad', 'unité', 'unite', 'wohnung', 'appartamento', 'unidade', 'apartamento', 'lot']); $cT = find_col($HX, ['tenant', 'resident', 'lessee', 'inquilino', 'locataire', 'mieter', 'inquilino', 'inquilino', 'locatário', 'name']);
    $cR = find_col($HX, ['rent', 'monthly rent', 'alquiler', 'loyer', 'miete', 'affitto', 'aluguel', 'renta']); $cD = find_col($H, ['due', 'due date', 'rent due', 'vencimiento', 'échéance', 'fällig', 'scadenza', 'vencimento']); $cP = find_col($H, ['paid', 'payment', 'received', 'pagado', 'payé', 'bezahlt', 'pagato', 'pago']);
    $cE = find_col($H, ['lease end', 'end date', 'expiry', 'expires', 'lease expiry', 'lease_end', 'fin de bail', 'mietende', 'scadenza contratto', 'fin', 'fim', 'vencimiento contrato']); $cDep = find_col($H, ['deposit', 'security deposit', 'depósito', 'depot', 'dépôt', 'kaution', 'caparra', 'caução', 'caución']); $cC = find_col($H, ['currency', 'moneda', 'devise', 'währung', 'valuta', 'moeda']);
    if ($cR === null) fail('no rent column found (rent, loyer, Miete, alquiler, affitto ...)'); if ($cD === null && $cE === null && $cDep === null) fail('no due, lease-end or deposit column found');
    $today = $opt['today'] ?? date('Y-m-d'); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $today) || !checkdate((int)substr($today, 5, 2), (int)substr($today, 8, 2), (int)substr($today, 0, 4))) fail('today= must be a date like 2026-03-01');
    $soon = isset($opt['soon']) && ctype_digit($opt['soon']) ? (int)$opt['soon'] : 7; $within = isset($opt['within']) && ctype_digit($opt['within']) ? (int)$opt['within'] : 60;
    $dmy = true; $a1 = 0; $a2 = 0; foreach ([$cD, $cE] as $ci) if ($ci !== null) foreach ($R as $r) if (preg_match('/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-]\d{2,4}/', trim($r['cells'][$ci] ?? ''), $m)) { if ((int)$m[1] > 12) $a1++; if ((int)$m[2] > 12) $a2++; } if ($a2 > 0 && $a1 === 0) $dmy = false;
    $days = fn($d) => (int)round((strtotime($d) - strtotime($today)) / 86400); $over = []; $up = []; $ends = []; $ended = []; $dep = []; $nodep = []; $errs = 0; $warn = 0; $seen = []; $paidN = 0; $partial = [];
    foreach ($R as $r) { $c = $r['cells']; $l = $r['line']; $unit = trim($c[$cU ?? 0] ?? ''); $ten = $cT !== null ? trim($c[$cT] ?? '') : ''; $who = $unit . ($ten !== '' ? " ($ten)" : ''); $cur = $cC !== null ? strtoupper(trim($c[$cC] ?? '')) : ''; $rent = parse_num($c[$cR] ?? '');
        if ($rent === null) { $msgs[] = "$name($l) Error: the rent \"" . ($c[$cR] ?? '') . '" cannot be read'; $errs++; } elseif ($rent <= 0) { $msgs[] = "$name($l) Warning: the rent for $who is zero or negative"; $warn++; }
        if ($cD !== null && trim($c[$cD] ?? '') !== '') { $due = parse_date($c[$cD], $dmy);
            if ($due === null) { $msgs[] = "$name($l) Error: the due date \"" . $c[$cD] . '" cannot be read'; $errs++; }
            else { $k = $unit . '|' . $due; if (isset($seen[$k]) && $unit !== '') { $msgs[] = "$name($l) Warning: $who is listed twice for the rent due $due (line {$seen[$k]})"; $warn++; } else $seen[$k] = $l;
                $pv = $cP !== null ? trim($c[$cP] ?? '') : ''; $paid = false; $short = null;
                if ($pv !== '') { if (preg_match('/^(y|yes|paid|true|x|✓|✔|1|oui|payé|ja|bezahlt|si|sì|sí|pagado|pagato|sim|pago)$/iu', $pv)) $paid = true; elseif (preg_match('/^(n|no|nein|non|unpaid|false|0|-)$/iu', $pv)) $paid = false; elseif (parse_date($pv, $dmy) !== null && !preg_match('/^[\d.,]+$/', $pv)) $paid = true; else { $am = parse_num($pv); if ($am !== null && $rent !== null) { if ($am + 0.005 >= $rent) $paid = true; elseif ($am > 0) { $short = $rent - $am; } } } }
                if ($paid) $paidN++; elseif ($rent !== null) { $d = $days($due); $amt = $short ?? $rent; if ($short !== null) $partial[] = [$l, $who, $short, $cur]; if ($d < 0) { $over[] = [$l, $who, $due, -$d, $amt, $cur]; $msgs[] = "$name($l) Warning: rent for $who was due $due: " . (-$d) . ' day(s) overdue, ' . number_format($amt, 2) . ($cur !== '' ? " $cur" : '') . ($short !== null ? ' still owing (part paid)' : ''); $warn++; } elseif ($d <= $soon) { $up[] = [$l, $who, $due, $d, $amt, $cur]; $msgs[] = "$name($l) Note: rent for $who is due $due (" . ($d === 0 ? 'today' : "in $d day(s)") . ')'; } } } }
        if ($cE !== null && trim($c[$cE] ?? '') !== '') { $le = parse_date($c[$cE], $dmy); if ($le === null) { $msgs[] = "$name($l) Error: the lease end \"" . $c[$cE] . '" cannot be read'; $errs++; } else { $lk = $unit . '|' . $ten . '|' . $le; if (isset($leaseSeen[$lk])) goto lease_done; $leaseSeen[$lk] = 1; $d = $days($le); if ($d < 0) { $ended[] = [$l, $who, $le, -$d]; $msgs[] = "$name($l) Warning: the lease of $who ended $le (" . (-$d) . ' day(s) ago): renewed, holding over or vacated?'; $warn++; } elseif ($d <= $within) { $ends[] = [$l, $who, $le, $d]; $msgs[] = "$name($l) Warning: the lease of $who ends $le (in $d day(s))"; $warn++; } } } lease_done:
        if ($cDep !== null) { $dv = trim($c[$cDep] ?? ''); if ($dv === '') $nodep[] = $who; else { $dn = parse_num($dv); if ($dn === null) { $msgs[] = "$name($l) Error: the deposit \"$dv\" cannot be read"; $errs++; } else { $key = $cur !== '' ? $cur : '(no currency)'; $dep[$key][$unit . '|' . $ten] = $dn; } } } }
    usort($over, fn($a, $b) => $b[3] <=> $a[3]); usort($ends, fn($a, $b) => $a[3] <=> $b[3]); usort($up, fn($a, $b) => $a[3] <=> $b[3]); $cs = fn($c) => $c !== '' ? " $c" : '';
    $o = "# Rent roll: $name\n\n- Today: $today (change it with today=); \"upcoming\" means due within $soon days (soon=); \"ending soon\" means a lease ending within $within days (within=)\n- Rows: " . count($R) . "; rent marked paid: $paidN\n\n";
    $o .= "## Overdue rent (" . count($over) . ")\n\n"; if ($over) { $o .= "| Line | Unit (tenant) | Due | Days overdue | Owing |\n|---|---|---|---|---|\n"; $tot = []; foreach ($over as [$l, $w, $d, $n, $a, $c]) { $o .= "| $l | " . str_replace('|', '/', $w) . " | $d | $n | " . number_format($a, 2) . $cs($c) . " |\n"; $tot[$c] = ($tot[$c] ?? 0) + $a; } $o .= "\nTotal owing: " . implode('; ', array_map(fn($c, $v) => number_format($v, 2) . ($c !== '' ? " $c" : ''), array_keys($tot), $tot)) . "\n"; } else $o .= "None.\n";
    $o .= "\n## Upcoming rent (" . count($up) . ")\n\n"; if ($up) foreach ($up as [$l, $w, $d, $n, $a, $c]) $o .= "- $name($l) $w: " . number_format($a, 2) . $cs($c) . " due $d (" . ($n === 0 ? 'today' : "in $n days") . ")\n"; else $o .= "None.\n";
    $o .= "\n## Leases ending within $within days (" . count($ends) . ")\n\n"; if ($ends) foreach ($ends as [$l, $w, $d, $n]) $o .= "- $name($l) $w: ends $d (in $n days)\n"; else $o .= "None.\n"; if ($ended) { $o .= "\n## Leases already ended (" . count($ended) . ")\n\n"; foreach ($ended as [$l, $w, $d, $n]) $o .= "- $name($l) $w: ended $d ($n days ago)\n"; }
    if ($cDep !== null) { $o .= "\n## Deposits held\n\n"; if ($dep) { $o .= "| Currency | Total | Units |\n|---|---|---|\n"; foreach ($dep as $c => $v) $o .= "| $c | " . number_format(array_sum($v), 2) . ' | ' . count($v) . " |\n"; $o .= "\nA tenancy listed on several rows is counted once (the last deposit shown for it).\n"; } else $o .= "None recorded.\n"; if ($nodep) $o .= "\nNo deposit recorded for: " . implode(', ', array_slice(array_unique($nodep), 0, 20)) . "\n"; }
    $o .= "\n" . DISCL . "\n"; if ($msgs) $o .= "\n## Problems ($errs errors, $warn warnings)\n\n" . implode("\n", $msgs) . "\n"; file_put_contents($out, $o . "\n[exit code " . ($errs ? 1 : 0) . "]\n"); echo "Wrote $out (" . count($over) . " overdue, " . count($ends) . " leases ending soon)\n"; exit(0);
}

if ($sub === 'calc') {
    $P = []; foreach (preg_split('/\n/', text_file($in)) as $ln) { $ln = trim($ln); if ($ln === '' || $ln[0] === '#') continue; if (preg_match('/^([A-Za-z_ ]{2,20})\s*[=:]\s*(.+)$/u', $ln, $m)) $P[strtolower(trim($m[1]))] = trim($m[2]); } $P = array_merge($P, $opt);
    $raw = fn(string $k) => $P[$k] ?? null; $num = function (string $k) use ($P): ?float { if (!isset($P[$k]) || $P[$k] === '') return null; $v = parse_num(rtrim($P[$k], '% ')); if ($v === null) fail("$k=\"{$P[$k]}\" is not a number"); if ($v < 0) fail("$k cannot be negative"); return $v; };
    $price = $num('price'); $rent = $num('rent'); if ($price === null || $price <= 0) fail('give price= (the purchase price), above zero'); if ($rent === null || $rent <= 0) fail('give rent= (the rent per month), above zero');
    $vac = $num('vacancy') ?? 0.0; $mgmt = $num('management') ?? 0.0; if ($vac > 100 || $mgmt > 100) fail('vacancy and management are percentages of the rent, at most 100'); $rate = $num('rate'); $years = $num('years'); if ($rate !== null && $rate > 100) fail('rate= is a yearly percentage, at most 100'); if ($years !== null && ($years <= 0 || $years > 60)) fail('years= must be between 0 and 60');
    $down = null; if (isset($P['down'])) { $d = $num('down'); $down = substr(trim($P['down']), -1) === '%' ? $price * $d / 100 : $d; if ($down > $price) fail('the down payment is more than the price'); } $closing = $num('closing') ?? 0.0;
    $opx = ['tax' => $num('tax'), 'insurance' => $num('insurance'), 'maintenance' => $num('maintenance'), 'other' => $num('other')]; $opexY = array_sum(array_map(fn($v) => $v ?? 0, $opx)); $given = array_keys(array_filter($opx, fn($v) => $v !== null));
    $grossY = $rent * 12; $eff = $grossY * (1 - $vac / 100); $mg = $eff * $mgmt / 100; $noi = $eff - $mg - $opexY;
    $o = "# Property numbers: $name\n\nEverything below comes from the figures you gave; nothing is assumed. Leaving out a cost makes the result look better than it is.\n\n## Income and yield\n\n- Price: " . number_format($price, 2) . "; rent " . number_format($rent, 2) . " a month = " . number_format($grossY, 2) . " a year\n- Gross yield (yearly rent / price): **" . number_format(100 * $grossY / $price, 2) . "%**; gross rent multiplier (price / yearly rent): " . number_format($price / $grossY, 1) . "\n";
    $o .= "- Vacancy $vac% takes " . number_format($grossY - $eff, 2) . "; management $mgmt% takes " . number_format($mg, 2) . "; yearly costs you gave (" . ($given ? implode(', ', $given) : 'none') . "): " . number_format($opexY, 2) . "\n- Net operating income (before any mortgage): " . number_format($noi, 2) . " a year; net yield (cap rate, NOI / price): **" . number_format(100 * $noi / $price, 2) . "%**\n";
    $pay = null; $loan = null;
    if ($down !== null && $rate !== null && $years !== null) { $loan = $price - $down; $r = $rate / 1200; $n = (int)round($years * 12); if ($loan <= 0) $o .= "\n## Mortgage\n\nThe down payment covers the whole price: no loan.\n"; else { $pay = $r > 0 ? $loan * $r / (1 - pow(1 + $r, -$n)) : $loan / $n; $tot = $pay * $n;
        $o .= "\n## Mortgage\n\n- Loan: " . number_format($loan, 2) . " (price minus down payment " . number_format($down, 2) . ") at $rate% a year over $years year(s) = $n monthly payments\n- **Monthly payment (principal and interest): " . number_format($pay, 2) . "**\n- Total paid over the term: " . number_format($tot, 2) . ", of which interest " . number_format($tot - $loan, 2) . "\n- Fixed rate, equal payments, paid monthly; fees, insurance and taxes on the loan are not included, and the lender's figure can differ.\n"; } }
    else $o .= "\n## Mortgage\n\nNot worked out: give down= (an amount, or 20%), rate= (yearly %) and years=.\n";
    if ($pay !== null) { $cf = $noi / 12 - $pay; $cash = $down + $closing; $o .= "\n## Cash flow\n\n- Net operating income per month: " . number_format($noi / 12, 2) . "; minus the mortgage payment " . number_format($pay, 2) . "\n- **Monthly cash flow: " . number_format($cf, 2) . "** (" . number_format($cf * 12, 2) . " a year)\n- Cash put in (down payment + closing costs " . number_format($closing, 2) . "): " . number_format($cash, 2) . "\n" . ($cash > 0 ? '- Cash-on-cash return (yearly cash flow / cash put in): **' . number_format(100 * $cf * 12 / $cash, 2) . "%**\n" : '') . '- Debt service coverage (NOI / yearly mortgage payments): ' . number_format($noi / ($pay * 12), 2) . " (below 1 means the rent does not cover the loan)\n"; if ($years !== null) $o .= "- Loan repayments that reduce what you owe are counted as cash out here, not as a cost of owning.\n"; }
    file_put_contents($out, $o . "\nPrice growth, tax on the income, repairs beyond what you listed, and changes in rent or rates are not modelled. " . DISCL . "\n\n[exit code 0]\n"); echo "Wrote $out\n"; exit(0);
}

// ---- listing -----------------------------------------------------------------------------------------------------------------------------------
$text = text_file($in); $lines = preg_split('/\n/', str_replace("\r", '', $text)); $errs = 0; $warn = 0; $found = [];
$pat = [
    'area' => ['/(\d[\d.,]*)\s*(m²|m2|㎡|sqm|sq\.?\s?m|square\s+met(?:er|re)s?|mq|metri\s+quadri|mètres?\s+carrés?|m\s?carrés?|quadratmeter|qm|metros?\s+cuadrados?|metros?\s+quadrados?|sq\.?\s?ft|square\s+feet|sqft|ft²|pieds?\s+carrés?)/iu', 'the floor area (m2 or square feet)'],
    'rooms' => ['/(\d+(?:[.,]5)?)\s*[- ]?(bed(?:room)?s?|br\b|rooms?|pièces?|chambres?|zimmer|camere?|locali|habitaci(?:ón|ones)|dormitorios?|quartos?|ambientes?|slaapkamers?)|(?:rooms?|bedrooms?|beds|chambres|zimmer|habitaciones|dormitorios|quartos)\s*[:=]\s*\d+|(studio|monolocale|estudio|bedsit|loft)\b/iu', 'the number of rooms or bedrooms'],
    'price' => ['/(?:[€$£¥₹]\s?\d[\d.,]*|\d[\d.,]*\s?(?:€|\$|£|¥|₹|eur\b|euros?|usd\b|gbp\b|chf\b|dollars?|pounds?)|(?:price|prix|preis|prezzo|precio|preço|rent|loyer|miete|affitto|alquiler|aluguel)\s*[:=]?\s*\d[\d.,]*)/iu', 'the price or the rent'],
    'availability' => ['/(available|availability|vacant|move.?in|immediately|disponible|libre|verfügbar|frei ab|bezugsfrei|disponibile|libero|disponível|livre|ab sofort|subito|inmediatamente|immédiatement)/iu', 'when it is available'],
];
$labels = ['area' => 'area', 'rooms' => 'rooms', 'price' => 'price', 'availability' => 'availability'];
foreach ($pat as $k => [$re, $what]) foreach ($lines as $i => $ln) if (preg_match_all($re, $ln, $mm)) foreach ($mm[0] as $one) $found[$k][] = [$i + 1, trim($one)];
foreach ($pat as $k => [$re, $what]) { if (empty($found[$k])) { $msgs[] = "$name(1) Warning: $what is not stated"; $warn++; } }
if (!empty($found['availability'])) { $hasDate = false; foreach ($lines as $ln) if (preg_match('/(avail|disponible|disponibile|disponível|verfügbar|frei ab|libre|libero|livre|move.?in|from|dès|ab|dal|desde|a partir)[^.\n]{0,25}(\d{1,2}[\/.\-]\d{1,2}|\d{4}-\d{2}|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|janv|févr|avr|juil|août|märz|mai|okt|dez|gen|mag|giu|lug|ago|set|ott|ene|abr|dic)\w*|now|immediately|ab sofort|immédiatement|subito|inmediatamente|agora)/iu', $ln)) $hasDate = true; if (!$hasDate) { $msgs[] = "$name(" . $found['availability'][0][0] . ') Note: "available" is stated without a date or "now": say from when'; } }
if (!empty($found['price'])) { $vals = []; foreach ($found['price'] as [$l, $s]) if (preg_match('/\d[\d.,]*/', $s, $m)) $vals[] = parse_num($m[0]); $u = array_unique(array_filter($vals, fn($v) => $v !== null)); if (count($u) > 1) { $msgs[] = "$name(" . $found['price'][0][0] . ') Note: several different amounts appear (' . implode(', ', array_map(fn($v) => number_format($v, 2, '.', ''), $u)) . '): make sure each is labelled (price, deposit, fees, monthly charges)'; }
    if (!preg_match('/(per month|a month|monthly|\/\s?mo|pcm|p\.?m\.?\b|par mois|mensuel|pro monat|monatlich|al mese|mensile|por mes|al mes|mensal|por mês|total|sale|for sale|à vendre|zu verkaufen|vendita|venta|venda|per week|per night|per year|an? year)/iu', $text)) { $msgs[] = "$name(" . $found['price'][0][0] . ') Note: it is not clear whether the price is a sale price or a rent, and per what period'; } }
if (!empty($found['area'])) { $vals = []; foreach ($found['area'] as [$l, $s]) if (preg_match('/^(\d[\d.,]*)/', $s, $m)) $vals[] = $s; if (count(array_unique(array_map(fn($s) => preg_replace('/\s+/', '', mb_strtolower($s)), $vals))) > 1) $msgs[] = "$name(" . $found['area'][0][0] . ') Note: more than one area appears (' . implode('; ', array_unique($vals)) . '): say which is living area, plot, total'; }
// claims nobody can verify
$claims = '/\b(best|perfect|perfectly|ideal|dream|luxur(?:y|ious)|stunning|amazing|incredible|unbeatable|bargain|cheap|must.see|won.t last|rare opportunity|once in a lifetime|prime location|great location|excellent location|exceptional|fantastic|gorgeous|breathtaking|spectacular|unique|charming|cosy|cozy|quiet|peaceful|safe area|sought.after|close to everything|walking distance|minutes from|great potential|huge potential|investment opportunity|high returns?|guaranteed|no risk|risk.free|can.t lose|will (?:rise|increase|appreciate) in value|newly renovated|recently renovated|fully renovated|modern|spacious|bright|sunny|large|huge|meilleur|parfait|idéal|luxueux|exceptionnel|magnifique|calme|rare|emplacement exceptionnel|beste[rnms]?|perfekt|traumhaft|einzigartig|exklusiv|ruhig|top.lage|ottimo|perfetto|esclusivo|splendido|tranquillo|occasione|melhor|perfeito|excelente|tranquilo|oportunidad|mejor|perfecto|espectacular|exclusivo|ocasión)(?:e[nmrs]?|s|a|o|i)?\b/iu';
$hard = '/(guaranteed|no risk|risk.free|can.t lose|will (?:rise|increase|appreciate) in value|high returns?|garanti|sans risque|garantiert|risikolos|garantito|senza rischio|garantizado|sin riesgo|garantido|sem risco)/iu'; $cn = 0;
$cn = 0; foreach ($lines as $i => $ln) { if (preg_match_all($claims, $ln, $m)) { $w = array_unique(array_map('mb_strtolower', $m[0])); $hw = array_values(array_filter($w, fn($x) => preg_match($hard, $x))); $sw = array_values(array_diff($w, $hw));
    if ($hw) { $msgs[] = "$name(" . ($i + 1) . ') Warning: a promise nobody can keep or prove (' . implode(', ', $hw) . '): remove it, or state the fact that backs it. Return and price claims can be a legal matter in some places.'; $warn++; }
    if ($sw) $msgs[] = "$name(" . ($i + 1) . ') Note: unverifiable wording (' . implode(', ', $sw) . '): replace with a fact (a date, a distance, a measurement) or drop it'; $cn += count($w); } }
if (preg_match_all('/\b(walking distance|minutes from|close to|near|à proximité|nahe|vicino|cerca)\b[^.\n]{0,40}/iu', $text, $mm) && !preg_match('/\d+\s*(min|minutes?|m\b|km|metres?|meters?|miles?|mi\b)/iu', $text)) $msgs[] = "$name(1) Note: \"close to\" or \"near\" appears with no distance or time: give it (for example 8 minutes on foot)";
if (preg_match('/(newly|recently|fully|neu|neuf|nouvellement|ristrutturat\w+|reformad\w+|renovad\w+)\s*(renovated|refurbished|rénové|saniert|renoviert)?/iu', $text) && preg_match('/renovat|refurb|rénov|saniert|renovier|ristruttur|reform|renovad/iu', $text) && !preg_match('/(renovat|refurb|rénov|saniert|renovier|ristruttur|reform|renovad)\w*[^.\n]{0,40}(19|20)\d\d|(19|20)\d\d[^.\n]{0,40}(renovat|refurb|rénov|saniert|renovier|ristruttur|reform|renovad)/iu', $text)) $msgs[] = "$name(1) Note: a renovation is claimed without a year or a description of what was done";
usort($msgs, fn($a, $b) => (int)preg_replace('/^[^(]*\((\d+)\).*/s', '$1', $a) <=> (int)preg_replace('/^[^(]*\((\d+)\).*/s', '$1', $b));
$present = []; foreach ($pat as $k => [$re, $what]) $present[$k] = !empty($found[$k]);
$o = "# Listing check: $name\n\n## Required facts\n\n| Fact | Found | Where |\n|---|---|---|\n"; foreach ($pat as $k => [$re, $what]) { $o .= '| ' . $labels[$k] . ' | ' . (!empty($found[$k]) ? 'yes' : '**missing**') . ' | ' . (!empty($found[$k]) ? 'line ' . $found[$k][0][0] . ': ' . str_replace('|', '/', mb_substr($found[$k][0][1], 0, 40)) : '') . " |\n"; }
if (!empty($found['area']) && !empty($found['price'])) { $a = null; $p = null; foreach ($found['area'] as [$l, $s]) if (preg_match('/^(\d[\d.,]*)/', $s, $m)) { $a = parse_num($m[1]); break; } foreach ($found['price'] as [$l, $s]) if (preg_match('/\d[\d.,]*/', $s, $m)) { $p = parse_num($m[0]); break; } if ($a > 0 && $p > 0) $o .= "\nFirst area and first price in the text: " . number_format($p / $a, 2) . " per unit of area (" . number_format($p, 2) . " / " . number_format($a, 2) . "): check that they are the figures you mean.\n"; }
$o .= "\nOther facts buyers and tenants usually ask for, which this check does not require: address or area, floor, heating and energy rating, monthly charges, deposit, pets, furnished or not, photos.\n\n## Problems ($errs errors, $warn warnings)\n\n" . ($msgs ? implode("\n", $msgs) . "\n" : "None.\n") . "\nFound by wording only, in several languages: read the listing yourself too. " . DISCL . "\n\n[exit code " . ($warn ? 1 : 0) . "]\n"; file_put_contents($out, $o); echo "Wrote $out ($warn warnings)\n";
