<?php
/* Copyright (C) 2026  Eduard Wisch  <data@data-it-solution.de>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 */

/**
 * \file    bankimport/class/bankstatement.class.php
 * \ingroup bankimport
 * \brief   Class for PDF bank statements from FinTS
 */

require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';

/**
 * Class BankImportStatement
 * Represents a PDF bank statement imported via FinTS
 */
class BankImportStatement extends CommonObject
{
	/**
	 * @var string ID to identify managed object
	 */
	public $element = 'bankstatement';

	/**
	 * @var string Name of table without prefix where object is stored
	 */
	public $table_element = 'bankimport_statement';

	/**
	 * @var int Entity
	 */
	public $entity;

	/**
	 * @var string IBAN
	 */
	public $iban;

	/**
	 * @var string Statement number
	 */
	public $statement_number;

	/**
	 * @var int Statement year
	 */
	public $statement_year;

	/**
	 * @var int Statement date
	 */
	public $statement_date;

	/**
	 * @var int Period from
	 */
	public $date_from;

	/**
	 * @var int Period to
	 */
	public $date_to;

	/**
	 * @var float Opening balance
	 */
	public $opening_balance;

	/**
	 * @var float Closing balance
	 */
	public $closing_balance;

	/**
	 * @var string Currency
	 */
	public $currency = 'EUR';

	/**
	 * @var string Filename
	 */
	public $filename;

	/**
	 * @var string Filepath
	 */
	public $filepath;

	/**
	 * @var int Filesize
	 */
	public $filesize;

	/**
	 * @var string Import batch key
	 */
	public $import_key;

	/**
	 * @var int Creation timestamp
	 */
	public $datec;

	/**
	 * @var int User who created
	 */
	public $fk_user_creat;

	/**
	 * @var string Private note
	 */
	public $note_private;

	/**
	 * @var string Public note
	 */
	public $note_public;

	/**
	 * Constructor
	 *
	 * @param DoliDB $db Database handler
	 */
	public function __construct($db)
	{
		global $conf;

		$this->db = $db;
		$this->entity = $conf->entity;
	}

	/**
	 * Create statement in database
	 *
	 * @param User $user User that creates
	 * @return int <0 if KO, Id of created object if OK
	 */
	public function create($user)
	{
		global $conf;

		$now = dol_now();

		$this->db->begin();

		$sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_statement (";
		$sql .= "entity, iban, statement_number, statement_year, statement_date,";
		$sql .= "date_from, date_to, opening_balance, closing_balance, currency,";
		$sql .= "filename, filepath, filesize, import_key, datec, fk_user_creat";
		$sql .= ") VALUES (";
		$sql .= ((int) $this->entity).",";
		$sql .= ($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").",";
		$sql .= "'".$this->db->escape($this->statement_number)."',";
		$sql .= ((int) $this->statement_year).",";
		$sql .= ($this->statement_date ? "'".$this->db->idate($this->statement_date)."'" : "NULL").",";
		$sql .= ($this->date_from ? "'".$this->db->idate($this->date_from)."'" : "NULL").",";
		$sql .= ($this->date_to ? "'".$this->db->idate($this->date_to)."'" : "NULL").",";
		$sql .= ($this->opening_balance !== null ? ((float) $this->opening_balance) : "NULL").",";
		$sql .= ($this->closing_balance !== null ? ((float) $this->closing_balance) : "NULL").",";
		$sql .= "'".$this->db->escape($this->currency)."',";
		$sql .= ($this->filename ? "'".$this->db->escape($this->filename)."'" : "NULL").",";
		$sql .= ($this->filepath ? "'".$this->db->escape($this->filepath)."'" : "NULL").",";
		$sql .= ($this->filesize ? ((int) $this->filesize) : "NULL").",";
		$sql .= ($this->import_key ? "'".$this->db->escape($this->import_key)."'" : "NULL").",";
		$sql .= "'".$this->db->idate($now)."',";
		$sql .= ((int) $user->id);
		$sql .= ")";

		dol_syslog(get_class($this)."::create", LOG_DEBUG);
		$resql = $this->db->query($sql);

		if ($resql) {
			$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX."bankimport_statement");
			$this->datec = $now;
			$this->fk_user_creat = $user->id;

			$this->db->commit();
			return $this->id;
		} else {
			$this->error = $this->db->lasterror();
			$this->db->rollback();
			return -1;
		}
	}

	/**
	 * Load statement from database
	 *
	 * @param int $id Id of statement to load
	 * @return int <0 if KO, 0 if not found, >0 if OK
	 */
	public function fetch($id)
	{
		$sql = "SELECT t.*";
		$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as t";
		$sql .= " WHERE t.rowid = ".((int) $id);

		dol_syslog(get_class($this)."::fetch", LOG_DEBUG);
		$resql = $this->db->query($sql);

		if ($resql) {
			if ($this->db->num_rows($resql)) {
				$obj = $this->db->fetch_object($resql);

				$this->id = $obj->rowid;
				$this->entity = $obj->entity;
				$this->iban = $obj->iban;
				$this->statement_number = $obj->statement_number;
				$this->statement_year = $obj->statement_year;
				$this->statement_date = $this->db->jdate($obj->statement_date);
				$this->date_from = $this->db->jdate($obj->date_from);
				$this->date_to = $this->db->jdate($obj->date_to);
				$this->opening_balance = $obj->opening_balance;
				$this->closing_balance = $obj->closing_balance;
				$this->currency = $obj->currency;
				$this->filename = $obj->filename;
				$this->filepath = $obj->filepath;
				$this->filesize = $obj->filesize;
				$this->import_key = $obj->import_key;
				$this->datec = $this->db->jdate($obj->datec);
				$this->fk_user_creat = $obj->fk_user_creat;
				$this->note_private = $obj->note_private;
				$this->note_public = $obj->note_public;

				$this->db->free($resql);
				return 1;
			} else {
				$this->db->free($resql);
				return 0;
			}
		} else {
			$this->error = $this->db->lasterror();
			return -1;
		}
	}

	/**
	 * Check if statement already exists (by number, year, iban)
	 *
	 * @return int 0 if not exists, rowid if exists
	 */
	public function exists()
	{
		$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."bankimport_statement";
		$sql .= " WHERE statement_number = '".$this->db->escape($this->statement_number)."'";
		$sql .= " AND statement_year = ".((int) $this->statement_year);
		$sql .= " AND iban = '".$this->db->escape($this->iban)."'";
		$sql .= " AND entity = ".((int) $this->entity);

		$resql = $this->db->query($sql);
		if ($resql) {
			if ($this->db->num_rows($resql) > 0) {
				$obj = $this->db->fetch_object($resql);
				return $obj->rowid;
			}
		}
		return 0;
	}

	/**
	 * Fetch all statements with filters
	 *
	 * @param string $sortfield Sort field
	 * @param string $sortorder Sort order (ASC/DESC)
	 * @param int $limit Limit
	 * @param int $offset Offset
	 * @param array $filter Filters array
	 * @param string $mode 'list' returns array, 'count' returns count
	 * @return array|int Array of statements, count or -1 on error
	 */
	public function fetchAll($sortfield = 'statement_year,statement_number', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list')
	{
		$sql = "SELECT t.rowid";
		$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as t";
		$sql .= " WHERE t.entity = ".((int) $this->entity);

		// Apply filters
		if (!empty($filter['iban'])) {
			$sql .= " AND t.iban LIKE '%".$this->db->escape($filter['iban'])."%'";
		}
		if (!empty($filter['year'])) {
			$sql .= " AND t.statement_year = ".((int) $filter['year']);
		}

		// Count mode
		if ($mode == 'count') {
			$sqlcount = preg_replace('/SELECT t\.rowid/', 'SELECT COUNT(*) as total', $sql);
			$resqlcount = $this->db->query($sqlcount);
			if ($resqlcount) {
				$objcount = $this->db->fetch_object($resqlcount);
				return (int) $objcount->total;
			}
			return 0;
		}

		// Sort and limit
		$sql .= $this->db->order($sortfield, $sortorder);
		if ($limit > 0) {
			$sql .= $this->db->plimit($limit, $offset);
		}

		dol_syslog(get_class($this)."::fetchAll", LOG_DEBUG);
		$resql = $this->db->query($sql);

		if ($resql) {
			$result = array();

			while ($obj = $this->db->fetch_object($resql)) {
				$statement = new BankImportStatement($this->db);
				$statement->fetch($obj->rowid);
				$result[] = $statement;
			}

			$this->db->free($resql);
			return $result;
		} else {
			$this->error = $this->db->lasterror();
			return -1;
		}
	}

	/**
	 * Delete statement
	 *
	 * @param User $user User that deletes
	 * @return int <0 if KO, >0 if OK
	 */
	public function delete($user)
	{
		$this->db->begin();

		// Delete file if exists
		if ($this->filepath && file_exists($this->filepath)) {
			@unlink($this->filepath);
		}

		// Delete statement lines
		$sqlLines = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement_line";
		$sqlLines .= " WHERE fk_statement = ".((int) $this->id);
		$this->db->query($sqlLines);

		$sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement";
		$sql .= " WHERE rowid = ".((int) $this->id);

		dol_syslog(get_class($this)."::delete", LOG_DEBUG);
		$resql = $this->db->query($sql);

		if ($resql) {
			$this->db->commit();
			return 1;
		} else {
			$this->error = $this->db->lasterror();
			$this->db->rollback();
			return -1;
		}
	}

	/**
	 * Get full path to PDF file
	 *
	 * @return string Full path or empty string
	 */
	public function getFilePath()
	{
		if ($this->filepath && file_exists($this->filepath)) {
			return $this->filepath;
		}
		return '';
	}

	/**
	 * Get storage directory for statements
	 *
	 * @return string Directory path
	 */
	public static function getStorageDir()
	{
		global $conf;

		$dir = $conf->bankimport->dir_output.'/statements';
		if (!is_dir($dir)) {
			dol_mkdir($dir);
		}
		return $dir;
	}

	/**
	 * Save PDF content to file
	 *
	 * @param string $pdfContent Binary PDF content
	 * @return int <0 if KO, >0 if OK
	 */
	public function savePDF($pdfContent)
	{
		$dir = self::getStorageDir();

		// Generate filename
		$this->filename = sprintf('statement_%s_%d_%s.pdf',
			preg_replace('/[^A-Z0-9]/', '', $this->iban),
			$this->statement_year,
			$this->statement_number
		);

		$this->filepath = $dir.'/'.$this->filename;

		// Write file
		$result = file_put_contents($this->filepath, $pdfContent);

		if ($result !== false) {
			$this->filesize = strlen($pdfContent);
			return 1;
		}

		$this->error = 'Failed to write PDF file';
		return -1;
	}

	/**
	 * Save uploaded PDF file
	 *
	 * @param array $fileInfo Element from $_FILES array
	 * @return int <0 if KO, >0 if OK
	 */
	public function saveUploadedPDF($fileInfo)
	{
		// Validate upload
		if (empty($fileInfo['tmp_name']) || !is_uploaded_file($fileInfo['tmp_name'])) {
			$this->error = 'No file uploaded';
			return -1;
		}

		// Check file size (max 10MB)
		if ($fileInfo['size'] > 10 * 1024 * 1024) {
			$this->error = 'File too large (max 10MB)';
			return -1;
		}

		// Check MIME type
		$finfo = finfo_open(FILEINFO_MIME_TYPE);
		$mimeType = finfo_file($finfo, $fileInfo['tmp_name']);
		finfo_close($finfo);

		if ($mimeType !== 'application/pdf') {
			$this->error = 'Only PDF files are allowed';
			return -1;
		}

		$dir = self::getStorageDir();

		// Generate filename
		$ibanPart = !empty($this->iban) ? preg_replace('/[^A-Z0-9]/', '', strtoupper($this->iban)) : 'KONTO';
		$this->filename = sprintf('Kontoauszug_%s_%d_%s.pdf',
			$ibanPart,
			$this->statement_year,
			str_pad($this->statement_number, 3, '0', STR_PAD_LEFT)
		);

		$this->filepath = $dir.'/'.$this->filename;

		// Check if file already exists
		if (file_exists($this->filepath)) {
			// Add timestamp to make unique
			$this->filename = sprintf('Kontoauszug_%s_%d_%s_%s.pdf',
				$ibanPart,
				$this->statement_year,
				str_pad($this->statement_number, 3, '0', STR_PAD_LEFT),
				date('His')
			);
			$this->filepath = $dir.'/'.$this->filename;
		}

		// Move uploaded file
		if (!move_uploaded_file($fileInfo['tmp_name'], $this->filepath)) {
			$this->error = 'Failed to save file';
			return -1;
		}

		$this->filesize = filesize($this->filepath);

		return 1;
	}

	/**
	 * Parse PDF bank statement metadata using pdfinfo and pdftotext
	 *
	 * Extracts: statement number, year, IBAN, date range, opening/closing balance,
	 * account number, bank name, statement date.
	 *
	 * @param string $filepath Path to PDF file
	 * @return array|false Array with extracted data or false on failure
	 */
	public static function parsePdfMetadata($filepath)
	{
		if (!file_exists($filepath)) {
			return false;
		}

		$result = array(
			'statement_number' => '',
			'statement_year' => 0,
			'pdf_number' => '',       // Original statement number from PDF (e.g. "1" from Nr. 1/2025)
			'pdf_year' => 0,          // Original year from PDF
			'iban' => '',
			'date_from' => null,
			'date_to' => null,
			'opening_balance' => null,
			'closing_balance' => null,
			'statement_date' => null,
			'account_number' => '',
			'bank_name' => '',
			'author' => '',
		);

		$escapedPath = escapeshellarg($filepath);

		// 1. Extract metadata via pdfinfo
		$pdfinfo = array();
		exec("pdfinfo ".$escapedPath." 2>/dev/null", $pdfinfo);

		foreach ($pdfinfo as $line) {
			if (preg_match('/^Title:\s+(.+)$/', $line, $m)) {
				// Title format: "000000000000000000000013438147 001/2025" or "Kontoauszug 13438147"
				if (preg_match('/(\d+)\s+(\d+)\/(\d{4})/', $m[1], $tm)) {
					$result['account_number'] = ltrim($tm[1], '0');
					$result['pdf_number'] = (string) intval($tm[2]);
					$result['pdf_year'] = (int) $tm[3];
				}
			}
			if (preg_match('/^Author:\s+(.+)$/', $line, $m)) {
				$result['author'] = trim($m[1]);
			}
		}

		// 2. Extract text via pdftotext
		$text = '';
		exec("pdftotext -layout ".$escapedPath." - 2>/dev/null", $textlines);
		$text = implode("\n", $textlines);

		// Statement number from text (fallback if not in metadata)
		if (empty($result['pdf_number']) && preg_match('/Nr\.\s+(\d+)\/(\d{4})/', $text, $m)) {
			$result['pdf_number'] = (string) intval($m[1]);
			$result['pdf_year'] = (int) $m[2];
		}

		// IBAN
		if (preg_match('/IBAN:\s*([A-Z]{2}\d{2}\s*[\d\s]+)/', $text, $m)) {
			$result['iban'] = preg_replace('/\s+/', ' ', trim($m[1]));
		}

		// Account number (fallback)
		if (empty($result['account_number']) && preg_match('/Kontonummer\s+(\d+)/', $text, $m)) {
			$result['account_number'] = $m[1];
		}

		// Date range from Kontoabschluss
		if (preg_match('/Kontoabschluss vom (\d{2}\.\d{2}\.\d{4}) bis (\d{2}\.\d{2}\.\d{4})/', $text, $m)) {
			$dateFrom = DateTime::createFromFormat('d.m.Y', $m[1]);
			$dateTo = DateTime::createFromFormat('d.m.Y', $m[2]);
			if ($dateFrom) {
				$result['date_from'] = $dateFrom->setTime(0, 0, 0)->getTimestamp();
			}
			if ($dateTo) {
				$result['date_to'] = $dateTo->setTime(0, 0, 0)->getTimestamp();
			}
		}

		// Statement date (erstellt am)
		if (preg_match('/erstellt am\s+(\d{2}\.\d{2}\.\d{4})/', $text, $m)) {
			$stmtDate = DateTime::createFromFormat('d.m.Y', $m[1]);
			if ($stmtDate) {
				$result['statement_date'] = $stmtDate->setTime(0, 0, 0)->getTimestamp();
			}
		}

		// Opening balance: "alter Kontostand [vom DD.MM.YYYY]   X.XXX,XX H/S"
		if (preg_match('/alter Kontostand(?:\s+vom\s+\d{2}\.\d{2}\.\d{4})?\s+([\d.,]+)\s+(H|S)/', $text, $m)) {
			$amount = self::parseGermanAmount($m[1]);
			if ($m[2] === 'S') {
				$amount = -$amount;
			}
			$result['opening_balance'] = $amount;
		}

		// Closing balance: "neuer Kontostand vom DD.MM.YYYY   X.XXX,XX H/S"
		if (preg_match('/neuer Kontostand(?:\s+vom\s+\d{2}\.\d{2}\.\d{4})?\s+([\d.,]+)\s+(H|S)/', $text, $m)) {
			$amount = self::parseGermanAmount($m[1]);
			if ($m[2] === 'S') {
				$amount = -$amount;
			}
			$result['closing_balance'] = $amount;
		}

		// Bank name (first line that contains "Bank" or known patterns)
		if (preg_match('/(?:VR\s*B\s*ank|Volksbank|Raiffeisenbank|Sparkasse)[^\n]*/i', $text, $m)) {
			$bankName = trim($m[0]);
			// Fix OCR artifacts: single chars separated by spaces ("V R B a n k" → "VRBank")
			// Strategy: collapse all single-space gaps between word chars that look like OCR splitting
			$bankName = preg_replace('/\b(\w) (\w) (\w) (\w)\b/', '$1$2$3$4', $bankName);
			$bankName = preg_replace('/\b(\w) (\w) (\w)\b/', '$1$2$3', $bankName);
			$bankName = preg_replace('/\b(\w) (\w)\b/', '$1$2', $bankName);
			// Fix common OCR pattern "VR B ank" → "VR Bank", "S chleswig" → "Schleswig"
			$bankName = preg_replace('/\bB ank\b/', 'Bank', $bankName);
			$bankName = preg_replace('/\bS (\w)/', 'S$1', $bankName);
			$bankName = preg_replace('/\bW (\w)/', 'W$1', $bankName);
			// Clean up multiple spaces and trim address parts after comma
			$bankName = preg_replace('/\s{2,}/', ' ', $bankName);
			$bankName = preg_replace('/,.*$/', '', $bankName);
			$result['bank_name'] = trim($bankName);
		}

		// Derive statement_number (=month) and statement_year from end date of period
		if ($result['date_to']) {
			$result['statement_number'] = (string) intval(date('m', $result['date_to']));
			$result['statement_year'] = (int) date('Y', $result['date_to']);
		} elseif ($result['date_from']) {
			$result['statement_number'] = (string) intval(date('m', $result['date_from']));
			$result['statement_year'] = (int) date('Y', $result['date_from']);
		} elseif (!empty($result['pdf_year'])) {
			// Fallback to PDF metadata if no date range
			$result['statement_number'] = $result['pdf_number'];
			$result['statement_year'] = $result['pdf_year'];
		}

		// Fallback: extract data from filename if PDF tools returned nothing
		// Supports patterns like: 13438147_2025_Nr.001_Kontoauszug_vom_2025.07.01_timestamp.pdf
		if (empty($result['statement_number']) && empty($result['iban'])) {
			$basename = basename($filepath);
			if (preg_match('/(\d+)_(\d{4})_Nr\.?(\d+)/', $basename, $fm)) {
				$result['account_number'] = ltrim($fm[1], '0');
				$result['pdf_number'] = (string) intval($fm[3]);
				$result['pdf_year'] = (int) $fm[2];
				$result['statement_number'] = $result['pdf_number'];
				$result['statement_year'] = $result['pdf_year'];
			}
			if (preg_match('/vom[_\s](\d{4})\.(\d{2})\.(\d{2})/', $basename, $dm)) {
				$stmtDate = DateTime::createFromFormat('Y-m-d', $dm[1].'-'.$dm[2].'-'.$dm[3]);
				if ($stmtDate) {
					$result['statement_date'] = $stmtDate->setTime(0, 0, 0)->getTimestamp();
					if (empty($result['statement_number'])) {
						$result['statement_number'] = (string) intval($dm[2]);
						$result['statement_year'] = (int) $dm[1];
					}
				}
			}
		}

		// Validate: at least statement number or IBAN must be present
		if (empty($result['statement_number']) && empty($result['iban'])) {
			return false;
		}

		return $result;
	}

	/**
	 * Parse a German formatted amount (e.g., "3.681,45" → 3681.45)
	 *
	 * @param string $amount German formatted amount string
	 * @return float Parsed amount
	 */
	private static function parseGermanAmount($amount)
	{
		$amount = str_replace('.', '', $amount);   // Remove thousands separator
		$amount = str_replace(',', '.', $amount);  // Convert decimal separator
		return (float) $amount;
	}

	/**
	 * Generate a clean filename for a PDF statement
	 *
	 * @param array $parsed Parsed metadata from parsePdfMetadata()
	 * @return string Generated filename
	 */
	public static function generateFilename($parsed)
	{
		$bank = 'Bank';
		if (!empty($parsed['bank_name'])) {
			// Shorten bank name - take first meaningful words
			$bank = preg_replace('/\s+(eG|AG|e\.G\.).*$/', '', $parsed['bank_name']);
			$bank = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß-]/', '_', $bank);
			$bank = preg_replace('/_+/', '_', $bank);
			$bank = trim($bank, '_');
		}

		$account = !empty($parsed['account_number']) ? $parsed['account_number'] : 'Konto';
		$year = !empty($parsed['statement_year']) ? $parsed['statement_year'] : date('Y');
		$nr = !empty($parsed['statement_number']) ? str_pad($parsed['statement_number'], 3, '0', STR_PAD_LEFT) : '000';

		return sprintf('%s_%s_%d_%s.pdf', $bank, $account, $year, $nr);
	}

	/**
	 * Get next available statement number for a year
	 *
	 * @param int $year Year
	 * @return string Next statement number
	 */
	public function getNextStatementNumber($year)
	{
		$sql = "SELECT MAX(CAST(statement_number AS UNSIGNED)) as maxnum";
		$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement";
		$sql .= " WHERE statement_year = ".((int) $year);
		$sql .= " AND entity = ".((int) $this->entity);

		$resql = $this->db->query($sql);
		if ($resql) {
			$obj = $this->db->fetch_object($resql);
			$nextNum = ($obj->maxnum !== null) ? ((int) $obj->maxnum + 1) : 1;
			return (string) $nextNum;
		}
		return '1';
	}

	/**
	 * Get the end date (date_to) of the most recent statement
	 *
	 * @return int|null Timestamp of latest date_to, or null if none
	 */
	public function getLatestStatementEndDate()
	{
		$sql = "SELECT MAX(date_to) as last_date";
		$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement";
		$sql .= " WHERE entity = ".((int) $this->entity);

		$resql = $this->db->query($sql);
		if ($resql) {
			$obj = $this->db->fetch_object($resql);
			if ($obj->last_date) {
				return $this->db->jdate($obj->last_date);
			}
		}
		return null;
	}

	/**
	 * Get list of years that have stored statements
	 *
	 * @return array Array of years (descending)
	 */
	public function getAvailableYears()
	{
		$sql = "SELECT DISTINCT statement_year";
		$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement";
		$sql .= " WHERE entity = ".((int) $this->entity);
		$sql .= " ORDER BY statement_year DESC";

		$result = array();
		$resql = $this->db->query($sql);
		if ($resql) {
			while ($obj = $this->db->fetch_object($resql)) {
				$result[(int) $obj->statement_year] = (string) $obj->statement_year;
			}
			$this->db->free($resql);
		}
		return $result;
	}

	/**
	 * Reconcile bank entries using parsed statement lines.
	 *
	 * Strategy: Match each statement_line (parsed from PDF) to a llx_bank entry
	 * by amount + date (with tolerance). This is authoritative because the
	 * statement lines come directly from the bank's PDF and represent exactly
	 * what transactions belong to this statement.
	 *
	 * Matching priority:
	 * 1. Exact amount + exact date
	 * 2. Exact amount + date within 4 days tolerance
	 *
	 * @param User $user          User performing the reconciliation
	 * @param int  $bankAccountId Dolibarr bank account ID (llx_bank_account.rowid)
	 * @return int Number of reconciled entries, or -1 on error
	 */
	public function reconcileBankEntries($user, $bankAccountId)
	{
		if (empty($this->id) || empty($bankAccountId)) {
			$this->error = 'Missing required fields for reconciliation';
			return 0;
		}

		require_once DOL_DOCUMENT_ROOT.'/compta/bank/class/account.class.php';

		// Format statement number: "NR/YYYY" (e.g., "3/2025")
		$numReleve = $this->statement_number.'/'.$this->statement_year;

		// Get statement lines
		$lines = $this->getStatementLines();
		if (!is_array($lines) || empty($lines)) {
			// No statement lines parsed yet — nothing to reconcile
			$this->copyToDolibarrStatementDir($bankAccountId);
			return 0;
		}

		$reconciled = 0;
		$usedBankIds = array();

		foreach ($lines as $line) {
			$amount = (float) $line->amount;
			$dateBooking = $line->date_booking; // YYYY-MM-DD string

			// Step 1: Try exact amount + exact date
			$sqlMatch = "SELECT b.rowid";
			$sqlMatch .= " FROM ".MAIN_DB_PREFIX."bank as b";
			$sqlMatch .= " WHERE b.fk_account = ".((int) $bankAccountId);
			$sqlMatch .= " AND b.rappro = 0";
			$sqlMatch .= " AND b.amount = ".$amount;
			$sqlMatch .= " AND b.datev = '".$this->db->escape($dateBooking)."'";
			if (!empty($usedBankIds)) {
				$sqlMatch .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")";
			}
			$sqlMatch .= " LIMIT 1";

			$resMatch = $this->db->query($sqlMatch);
			$matched = false;

			if ($resMatch && $this->db->num_rows($resMatch) > 0) {
				$match = $this->db->fetch_object($resMatch);
				$matched = $this->reconcileBankLine($user, $match->rowid, $numReleve, $line->rowid, $usedBankIds);
			}

			// Step 2: Try exact amount + date within 4 days tolerance
			if (!$matched) {
				$sqlMatch2 = "SELECT b.rowid, ABS(DATEDIFF(b.datev, '".$this->db->escape($dateBooking)."')) as date_diff";
				$sqlMatch2 .= " FROM ".MAIN_DB_PREFIX."bank as b";
				$sqlMatch2 .= " WHERE b.fk_account = ".((int) $bankAccountId);
				$sqlMatch2 .= " AND b.rappro = 0";
				$sqlMatch2 .= " AND b.amount = ".$amount;
				$sqlMatch2 .= " AND ABS(DATEDIFF(b.datev, '".$this->db->escape($dateBooking)."')) <= 4";
				if (!empty($usedBankIds)) {
					$sqlMatch2 .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")";
				}
				$sqlMatch2 .= " ORDER BY date_diff ASC LIMIT 1";

				$resMatch2 = $this->db->query($sqlMatch2);
				if ($resMatch2 && $this->db->num_rows($resMatch2) > 0) {
					$match2 = $this->db->fetch_object($resMatch2);
					$matched = $this->reconcileBankLine($user, $match2->rowid, $numReleve, $line->rowid, $usedBankIds);
				}
			}

			// Step 3: Match by supplier invoice numbers in description
			if (!$matched && !empty($line->description)) {
				$matched = $this->reconcileByInvoiceNumbers($user, $line, $bankAccountId, $numReleve, $usedBankIds);
			}

			if ($matched) {
				$reconciled++;
			}
		}

		// Copy PDF to Dolibarr's bank statement document directory
		$this->copyToDolibarrStatementDir($bankAccountId);

		return $reconciled;
	}

	/**
	 * Reconcile a single bank line: set num_releve, rappro=1, link statement_line
	 *
	 * @param User  $user        User
	 * @param int   $bankRowId   llx_bank.rowid
	 * @param string $numReleve  Statement number (e.g. "3/2025")
	 * @param int   $lineRowId   llx_bankimport_statement_line.rowid
	 * @param array &$usedBankIds Reference to array of already used bank IDs
	 * @return bool True if reconciled successfully
	 */
	private function reconcileBankLine($user, $bankRowId, $numReleve, $lineRowId, &$usedBankIds)
	{
		$bankLine = new AccountLine($this->db);
		$bankLine->fetch($bankRowId);
		$bankLine->num_releve = $numReleve;

		$result = $bankLine->update_conciliation($user, 0, 1);
		if ($result >= 0) {
			$usedBankIds[] = (int) $bankRowId;

			// Link statement line to this bank entry
			$sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET";
			$sql .= " fk_bank = ".((int) $bankRowId);
			$sql .= ", match_status = 'reconciled'";
			$sql .= " WHERE rowid = ".((int) $lineRowId);
			$this->db->query($sql);

			return true;
		}
		return false;
	}

	/**
	 * Mark a statement line as pending review (matched by invoice number but amount
	 * difference exceeds threshold). Sets fk_bank as candidate but does NOT reconcile
	 * the bank entry (rappro stays 0).
	 *
	 * @param int   $bankRowId  llx_bank.rowid (candidate)
	 * @param int   $lineRowId  llx_bankimport_statement_line.rowid
	 * @return bool True if saved
	 */
	private function markPendingReview($bankRowId, $lineRowId)
	{
		$sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET";
		$sql .= " fk_bank = ".((int) $bankRowId);
		$sql .= ", match_status = 'pending_review'";
		$sql .= " WHERE rowid = ".((int) $lineRowId);

		return (bool) $this->db->query($sql);
	}

	/**
	 * Extract supplier invoice numbers from statement line description
	 * and find matching llx_bank entries via the payment chain.
	 *
	 * Patterns recognized:
	 * - /INV/9009414207 (Firmenlastschrift)
	 * - /ADV/0014494147 (Avise)
	 * - Beleg-Nr.: 9008468982 (Überweisungsauftrag)
	 *
	 * Chain: ref_supplier → llx_facture_fourn → llx_paiementfourn_facturefourn
	 *        → llx_paiementfourn → llx_bank_url → llx_bank
	 *
	 * @param User   $user          User
	 * @param object $line          Statement line object
	 * @param int    $bankAccountId Dolibarr bank account ID
	 * @param string $numReleve     Statement number (e.g. "3/2025")
	 * @param array  &$usedBankIds  Reference to array of already used bank IDs
	 * @return bool True if at least one bank entry was reconciled
	 */
	private function reconcileByInvoiceNumbers($user, $line, $bankAccountId, $numReleve, &$usedBankIds)
	{
		$desc = $line->description.' '.$line->name;

		// Extract invoice/reference numbers from description
		$refNumbers = array();

		// Pattern 1: /INV/XXXXXXXXXX
		if (preg_match_all('/\/INV\/(\d+)/', $desc, $m)) {
			$refNumbers = array_merge($refNumbers, $m[1]);
		}

		// Pattern 2: /ADV/XXXXXXXXXX
		if (preg_match_all('/\/ADV\/(\d+)/', $desc, $m)) {
			$refNumbers = array_merge($refNumbers, $m[1]);
		}

		// Pattern 3: Beleg-Nr.: XXXXXXXXXX or Beleg-Nr. XXXXXXXXXX
		if (preg_match_all('/Beleg-Nr\.?\s*:?\s*(\d{5,})/', $desc, $m)) {
			$refNumbers = array_merge($refNumbers, $m[1]);
		}

		if (empty($refNumbers)) {
			return false;
		}

		$refNumbers = array_unique($refNumbers);

		// Build escaped list for SQL IN clause
		$escapedRefs = array();
		foreach ($refNumbers as $ref) {
			$escapedRefs[] = "'".$this->db->escape($ref)."'";
		}
		$inClause = implode(',', $escapedRefs);

		// Find llx_bank entries linked to supplier payments for these invoice numbers
		$sql = "SELECT DISTINCT b.rowid, b.amount";
		$sql .= " FROM ".MAIN_DB_PREFIX."bank as b";
		$sql .= " JOIN ".MAIN_DB_PREFIX."bank_url as bu ON bu.fk_bank = b.rowid AND bu.type = 'payment_supplier'";
		$sql .= " JOIN ".MAIN_DB_PREFIX."paiementfourn as pf ON pf.rowid = bu.url_id";
		$sql .= " JOIN ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pfff ON pfff.fk_paiementfourn = pf.rowid";
		$sql .= " JOIN ".MAIN_DB_PREFIX."facture_fourn as ff ON ff.rowid = pfff.fk_facturefourn";
		$sql .= " WHERE b.fk_account = ".((int) $bankAccountId);
		$sql .= " AND b.rappro = 0";
		$sql .= " AND ff.ref_supplier IN (".$inClause.")";
		if (!empty($usedBankIds)) {
			$sql .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")";
		}

		dol_syslog(get_class($this)."::reconcileByInvoiceNumbers refs=".implode(',', $refNumbers), LOG_DEBUG);
		$resql = $this->db->query($sql);

		if (!$resql) {
			return false;
		}

		$matched = false;
		$stmtAmount = abs((float) $line->amount);

		while ($obj = $this->db->fetch_object($resql)) {
			$bankAmount = abs((float) $obj->amount);
			$diff = abs($stmtAmount - $bankAmount);

			if ($diff > 5.0) {
				// Differenz > 5 EUR: nur als Kandidat markieren, nicht abgleichen
				$this->markPendingReview($obj->rowid, $line->rowid);
				dol_syslog(get_class($this)."::reconcileByInvoiceNumbers PENDING bank=".$obj->rowid." diff=".$diff, LOG_WARNING);
			} else {
				// Differenz <= 5 EUR: automatisch abgleichen
				if ($this->reconcileBankLine($user, $obj->rowid, $numReleve, $line->rowid, $usedBankIds)) {
					$matched = true;
				}
			}
		}
		$this->db->free($resql);

		return $matched;
	}

	/**
	 * Copy the PDF file to Dolibarr's bank statement document directory
	 * so it appears in the Documents tab of account_statement_document.php
	 *
	 * Target: $conf->bank->dir_output."/".$bankAccountId."/statement/".dol_sanitizeFileName($numReleve)."/"
	 *
	 * @param int $bankAccountId Dolibarr bank account ID
	 * @return int 1 if OK, 0 if nothing to copy, -1 on error
	 */
	public function copyToDolibarrStatementDir($bankAccountId)
	{
		global $conf;

		if (empty($this->filepath) || !file_exists($this->filepath)) {
			return 0;
		}

		if (empty($bankAccountId) || empty($this->statement_number) || empty($this->statement_year)) {
			return 0;
		}

		$numReleve = $this->statement_number.'/'.$this->statement_year;
		$targetDir = $conf->bank->dir_output.'/'.((int) $bankAccountId).'/statement/'.dol_sanitizeFileName($numReleve);

		if (!is_dir($targetDir)) {
			dol_mkdir($targetDir);
		}

		$targetFile = $targetDir.'/'.$this->filename;

		// Don't copy if already exists with same size
		if (file_exists($targetFile) && filesize($targetFile) == filesize($this->filepath)) {
			return 1;
		}

		$result = @copy($this->filepath, $targetFile);
		if ($result) {
			@chmod($targetFile, 0664);
			return 1;
		}

		$this->error = 'Failed to copy PDF to '.$targetDir;
		return -1;
	}

	/**
	 * Parse individual transaction lines from a PDF bank statement.
	 *
	 * Extracts booking date, value date, transaction type, amount, counterparty name
	 * and description text from VR-Bank PDF statement format.
	 *
	 * @param string $filepath Path to PDF file (uses $this->filepath if empty)
	 * @return array Array of transaction arrays, each with keys:
	 *               date_booking, date_value, transaction_type, amount, name, description
	 */
	public function parsePdfTransactions($filepath = '')
	{
		if (empty($filepath)) {
			$filepath = $this->filepath;
		}
		if (empty($filepath) || !file_exists($filepath)) {
			return array();
		}

		$escapedPath = escapeshellarg($filepath);
		$textlines = array();
		exec("pdftotext -layout ".$escapedPath." - 2>/dev/null", $textlines);

		// Determine statement year from metadata
		$stmtYear = !empty($this->statement_year) ? (int) $this->statement_year : (int) date('Y');

		$transactions = array();
		$currentTx = null;
		$inTransactionBlock = false;
		$skipPageBreak = false; // True between "Übertrag auf" and "Übertrag von"

		foreach ($textlines as $line) {
			// Stop parsing at "Anlage" (fee detail section) or "Der ausgewiesene Kontostand"
			if (preg_match('/^\s*Anlage\s+\d/', $line) || preg_match('/Der ausgewiesene Kontostand/', $line)) {
				if ($currentTx !== null) {
					$transactions[] = $currentTx;
					$currentTx = null;
				}
				break;
			}

			// Handle page breaks: skip everything between "Übertrag auf Blatt" and "Übertrag von Blatt"
			if (preg_match('/Übertrag\s+auf\s+Blatt/', $line)) {
				// Save current transaction before page break
				if ($currentTx !== null) {
					$transactions[] = $currentTx;
					$currentTx = null;
				}
				$skipPageBreak = true;
				continue;
			}
			if (preg_match('/Übertrag\s+von\s+Blatt/', $line)) {
				$skipPageBreak = false;
				continue;
			}
			if ($skipPageBreak) {
				continue;
			}

			// Skip blank lines and decorative lines
			if (preg_match('/^\s*$/', $line) || preg_match('/────/', $line)) {
				continue;
			}

			// Detect start of transaction block
			if (preg_match('/Bu-Tag\s+Wert\s+Vorgang/', $line)) {
				$inTransactionBlock = true;
				continue;
			}

			if (!$inTransactionBlock) {
				continue;
			}

			// Skip balance lines
			if (preg_match('/alter Kontostand/', $line) || preg_match('/neuer Kontostand/', $line)) {
				if ($currentTx !== null) {
					$transactions[] = $currentTx;
					$currentTx = null;
				}
				continue;
			}

			// Transaction line: "DD.MM. DD.MM. Vorgangsart ... Betrag S/H"
			if (preg_match('/^\s+(\d{2})\.(\d{2})\.\s+(\d{2})\.(\d{2})\.\s+(.+)/', $line, $m)) {
				// Save previous transaction
				if ($currentTx !== null) {
					$transactions[] = $currentTx;
				}

				$bookDay = (int) $m[1];
				$bookMonth = (int) $m[2];
				$valDay = (int) $m[3];
				$valMonth = (int) $m[4];
				$rest = $m[5];

				$bookYear = $stmtYear;
				$valYear = $stmtYear;

				// Parse amount and S/H from the rest of the line
				$amount = 0;
				$txType = '';

				if (preg_match('/^(.+?)\s+([\d.,]+)\s+(S|H)\s*$/', $rest, $am)) {
					$txType = trim($am[1]);
					$amount = self::parseGermanAmount($am[2]);
					if ($am[3] === 'S') {
						$amount = -$amount;
					}
				} else {
					$txType = trim($rest);
				}

				// Build date strings
				$dateBooking = sprintf('%04d-%02d-%02d', $bookYear, $bookMonth, $bookDay);
				$dateValue = sprintf('%04d-%02d-%02d', $valYear, $valMonth, $valDay);

				$currentTx = array(
					'date_booking' => $dateBooking,
					'date_value' => $dateValue,
					'transaction_type' => $txType,
					'amount' => $amount,
					'name' => '',
					'description' => '',
				);
			} elseif ($currentTx !== null) {
				// Continuation line (counterparty name or description)
				$detail = trim($line);
				if (!empty($detail)) {
					if (empty($currentTx['name'])) {
						$currentTx['name'] = $detail;
					} else {
						$currentTx['description'] .= ($currentTx['description'] ? ' ' : '').$detail;
					}
				}
			}
		}

		// Don't forget the last transaction
		if ($currentTx !== null) {
			$transactions[] = $currentTx;
		}

		return $transactions;
	}

	/**
	 * Save parsed transaction lines to the database.
	 * Deletes existing lines for this statement first, then inserts new ones.
	 *
	 * @param array $transactions Array from parsePdfTransactions()
	 * @return int Number of lines saved, or -1 on error
	 */
	public function saveStatementLines($transactions)
	{
		if (empty($this->id)) {
			$this->error = 'Statement not saved yet';
			return -1;
		}

		$this->db->begin();

		// Delete existing lines for this statement
		$sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement_line";
		$sql .= " WHERE fk_statement = ".((int) $this->id);
		$this->db->query($sql);

		$now = dol_now();
		$count = 0;

		foreach ($transactions as $i => $tx) {
			$lineNum = $i + 1;

			$sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_statement_line (";
			$sql .= "fk_statement, entity, line_number, date_booking, date_value,";
			$sql .= "transaction_type, amount, currency, name, description, datec";
			$sql .= ") VALUES (";
			$sql .= ((int) $this->id).",";
			$sql .= ((int) $this->entity).",";
			$sql .= ((int) $lineNum).",";
			$sql .= "'".$this->db->escape($tx['date_booking'])."',";
			$sql .= (!empty($tx['date_value']) ? "'".$this->db->escape($tx['date_value'])."'" : "NULL").",";
			$sql .= (!empty($tx['transaction_type']) ? "'".$this->db->escape($tx['transaction_type'])."'" : "NULL").",";
			$sql .= ((float) $tx['amount']).",";
			$sql .= "'EUR',";
			$sql .= (!empty($tx['name']) ? "'".$this->db->escape($tx['name'])."'" : "NULL").",";
			$sql .= (!empty($tx['description']) ? "'".$this->db->escape($tx['description'])."'" : "NULL").",";
			$sql .= "'".$this->db->idate($now)."'";
			$sql .= ")";

			$resql = $this->db->query($sql);
			if ($resql) {
				$count++;
			} else {
				$this->error = $this->db->lasterror();
				$this->db->rollback();
				return -1;
			}
		}

		$this->db->commit();
		return $count;
	}

	/**
	 * Get statement lines from database
	 *
	 * @return array Array of line objects, or -1 on error
	 */
	public function getStatementLines()
	{
		$sql = "SELECT rowid, fk_statement, line_number, date_booking, date_value,";
		$sql .= " transaction_type, amount, currency, name, description, fk_bank, match_status";
		$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line";
		$sql .= " WHERE fk_statement = ".((int) $this->id);
		$sql .= " ORDER BY line_number ASC";

		$resql = $this->db->query($sql);
		if (!$resql) {
			$this->error = $this->db->lasterror();
			return -1;
		}

		$lines = array();
		while ($obj = $this->db->fetch_object($resql)) {
			$lines[] = $obj;
		}
		$this->db->free($resql);

		return $lines;
	}

	/**
	 * Link transactions to this statement based on date range and IBAN
	 *
	 * Updates all transactions that fall within the statement's date range
	 * and match the IBAN, setting their fk_statement to this statement's ID.
	 *
	 * @return int Number of linked transactions, or -1 on error
	 */
	public function linkTransactions()
	{
		if (empty($this->id) || empty($this->date_from) || empty($this->date_to)) {
			return 0;
		}

		$sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_transaction SET";
		$sql .= " fk_statement = ".((int) $this->id);
		$sql .= " WHERE entity = ".((int) $this->entity);
		$sql .= " AND date_trans >= '".$this->db->idate($this->date_from)."'";
		$sql .= " AND date_trans <= '".$this->db->idate($this->date_to)."'";
		$sql .= " AND fk_statement IS NULL"; // Don't overwrite existing links

		// Match by IBAN if available
		if (!empty($this->iban)) {
			$ibanClean = preg_replace('/\s+/', '', $this->iban);
			$sql .= " AND REPLACE(iban, ' ', '') = '".$this->db->escape($ibanClean)."'";
		}

		dol_syslog(get_class($this)."::linkTransactions", LOG_DEBUG);
		$resql = $this->db->query($sql);

		if ($resql) {
			return $this->db->affected_rows($resql);
		} else {
			$this->error = $this->db->lasterror();
			return -1;
		}
	}
}
