"""Parse Excel bank statements using a Bank Integration's File Format configuration."""

import re
from datetime import datetime, date

import frappe
from frappe.utils import flt


# Display labels (stored in DB on Bank Integration Excel Column Mapping.standard_field)
# mapped to the short internal codes the parser logic uses.
STANDARD_FIELD_TO_CODE = {
	"Date": "date",
	"Reference Number": "reference_number",
	"Counterparty": "counterparty",
	"Counterparty Tax ID (VOEN)": "counterparty_tax_id",
	"Counterparty IBAN": "counterparty_iban",
	"Amount": "amount",
	"Debit": "debit",
	"Credit": "credit",
	"Direction": "direction",
	"Currency": "currency",
	"Purpose": "purpose",
	"Description": "description",
}


# Internal codes that must be present in the headers of any imported file, by
# amount_mode. Kept in sync with bank_integration.py:_REQUIRED_BY_MODE.
_REQUIRED_CODES_BY_MODE = {
	"Single column with sign": ("date", "reference_number", "amount"),
	"Separate debit/credit columns": ("date", "reference_number", "debit", "credit"),
	"Single column + direction column": ("date", "reference_number", "amount", "direction"),
}


# Tried in order until one parses the value. Covers ISO-style, day-first (EU/AZ),
# and month-first (US) on the four common separators, with and without time.
# Day-first variants are listed BEFORE month-first to bias towards the AZ region;
# users with US-style ambiguous dates can enable "Use Custom Date Format".
_COMMON_DATE_FORMATS = (
	"%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d",
	"%d-%m-%Y", "%d.%m.%Y", "%d/%m/%Y", "%d\\%m\\%Y",
	"%m-%d-%Y", "%m.%d.%Y", "%m/%d/%Y",
	"%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M",
	"%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M",
	"%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M",
	"%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M",
	"%d-%m-%y", "%d.%m.%y", "%d/%m/%y",
	"%m-%d-%y", "%m.%d.%y", "%m/%d/%y",
)


def parse_excel(file_url, bank_integration):
	"""Parse an Excel bank statement using a Bank Integration's File Format.

	Returns a dict:
	    {
	        "transactions": [ {ref_no, date, counterparty, contr_voen, contr_iban,
	                           amount, currency, drcr, purpose, description}, ... ],
	        "dropped_date": int,    # rows skipped because date couldn't be parsed
	        "dropped_amount": int,  # rows skipped because amount was zero/missing
	    }
	"""
	from openpyxl import load_workbook

	bi = frappe.get_doc("Bank Statement Importer", bank_integration)

	col_to_standard = {}
	unknown_labels = []
	for row in bi.column_mappings:
		if not row.excel_column or not row.standard_field:
			continue
		code = STANDARD_FIELD_TO_CODE.get(row.standard_field)
		if not code:
			unknown_labels.append(row.standard_field)
			continue
		col_to_standard[row.excel_column.strip()] = code

	if unknown_labels:
		frappe.throw(
			"Bank Integration '{}' has unknown standard field(s): {}. Open the File Format tab and pick a value from the dropdown.".format(
				bank_integration, ", ".join(sorted(set(unknown_labels)))
			)
		)
	if not col_to_standard:
		frappe.throw(
			f"Bank Integration '{bank_integration}' has no column mappings. "
			"Open the File Format tab and map Excel columns to Standard Fields."
		)

	file_doc = frappe.get_doc("File", {"file_url": file_url})
	file_path = file_doc.get_full_path()

	wb = load_workbook(file_path, data_only=True, read_only=True)
	try:
		ws = wb.active

		header_row = bi.header_row or 1

		all_rows = list(ws.iter_rows(values_only=True))
		header_idx = header_row - 1
		if header_idx >= len(all_rows):
			frappe.throw(f"Header row {header_row} is out of range (file has {len(all_rows)} rows)")

		headers = [_normalize_header(c) for c in all_rows[header_idx]]
		normalized_lookup = {_normalize_header(k): v for k, v in col_to_standard.items()}

		idx_to_standard = {}
		for i, h in enumerate(headers):
			if h in normalized_lookup:
				idx_to_standard[i] = normalized_lookup[h]

		if not idx_to_standard:
			frappe.throw(
				f"None of the configured columns ({sorted(col_to_standard.keys())}) "
				f"found in Excel headers ({[h for h in headers if h]})"
			)

		amount_mode = bi.amount_mode or "Separate debit/credit columns"
		found_codes = set(idx_to_standard.values())
		required = _REQUIRED_CODES_BY_MODE.get(amount_mode, ())
		missing_codes = [c for c in required if c not in found_codes]
		if missing_codes:
			# Reverse-map codes back to human labels for the message.
			code_to_label = {v: k for k, v in STANDARD_FIELD_TO_CODE.items()}
			missing_labels = [code_to_label.get(c, c) for c in missing_codes]
			frappe.throw(
				"Required column(s) for Amount Mode '{}' not found in the file headers: {}. "
				"Either the Header Row points at the wrong line, or the Excel column "
				"names in the File Format tab don't match the file. Headers seen: {}".format(
					amount_mode, ", ".join(missing_labels), [h for h in headers if h]
				)
			)

		use_custom = bool(bi.get("use_custom_date_format"))
		custom_format = (bi.date_format or "").strip() if use_custom else ""
		debit_aliases = _split_aliases(bi.get("direction_debit_values"))
		credit_aliases = _split_aliases(bi.get("direction_credit_values"))

		if amount_mode == "Single column + direction column" and not (debit_aliases or credit_aliases):
			frappe.throw(
				"Bank Integration '{}' uses 'Single column + direction column' but has no Debit/Credit values configured. "
				"Open the File Format tab and fill in at least one of 'Debit Values' / 'Credit Values'.".format(bank_integration)
			)

		data_start = header_idx + 1

		transactions = []
		dropped_date = 0
		dropped_amount = 0
		dropped_direction = 0
		unknown_directions = set()
		for raw_row in all_rows[data_start:]:
			if not raw_row or all(c is None or str(c).strip() == "" for c in raw_row):
				continue

			parsed, reason, ctx = _parse_row(
				raw_row, idx_to_standard, custom_format, amount_mode,
				debit_aliases, credit_aliases,
			)
			if parsed:
				transactions.append(parsed)
			elif reason == "date":
				dropped_date += 1
			elif reason == "amount":
				dropped_amount += 1
			elif reason == "direction":
				dropped_direction += 1
				if ctx:
					unknown_directions.add(ctx)

		return {
			"transactions": transactions,
			"dropped_date": dropped_date,
			"dropped_amount": dropped_amount,
			"dropped_direction": dropped_direction,
			"unknown_directions": sorted(unknown_directions)[:20],
		}
	finally:
		wb.close()


def _split_aliases(raw):
	"""Parse a comma-separated alias list from a preset field into an uppercase set."""
	if not raw:
		return set()
	return {part.strip().upper() for part in str(raw).split(",") if part.strip()}


_AMOUNT_STRIP_RE = re.compile(r"[^\d,.\-]")


def _parse_amount(cell):
	"""Convert a money-like cell value into a float.

	Handles:
	- numeric cells (int/float) directly
	- currency text prefix/suffix: 'AZN 250.00', '250.00 AZN', 'USD 1,200.50'
	- comma decimal separator: '250,00' → 250.00
	- thousands + decimal: '1,200.50' / '1.200,50'
	- accounting-style negative in brackets: '(45.50)' → -45.50

	Returns float, or None if the cell is empty / unparseable.
	"""
	if cell is None:
		return None
	if isinstance(cell, bool):
		return None
	if isinstance(cell, (int, float)):
		return float(cell)
	s = str(cell).strip()
	if not s:
		return None

	negate = False
	if s.startswith("(") and s.endswith(")"):
		negate = True
		s = s[1:-1].strip()
	if s.endswith("-"):
		negate = not negate
		s = s[:-1].strip()

	# Drop currency text and stray symbols, keep digits, separators, minus.
	s = _AMOUNT_STRIP_RE.sub("", s)
	if s.count("-") > 1:
		s = s.replace("-", "")
		negate = not negate
	elif s.startswith("-"):
		negate = not negate
		s = s[1:]

	# Decimal separator detection. If both ',' and '.' appear, the rightmost is
	# the decimal one and the other is a thousands grouping. If only ',' appears,
	# assume it's the decimal separator.
	if "," in s and "." in s:
		if s.rfind(",") > s.rfind("."):
			s = s.replace(".", "").replace(",", ".")
		else:
			s = s.replace(",", "")
	elif "," in s:
		s = s.replace(",", ".")

	if not s or s in (".", "-"):
		return None
	try:
		v = float(s)
	except ValueError:
		return None
	return -v if negate else v


def _normalize_header(value):
	if value is None:
		return ""
	return " ".join(str(value).strip().lower().split())


def _parse_row(raw_row, idx_to_standard, custom_format, amount_mode,
               debit_aliases=None, credit_aliases=None):
	"""Returns (record_dict_or_None, reason, ctx).

	reason in {"", "date", "amount", "direction"}; ctx is the offending value
	when applicable (used to show the user which Direction strings were not
	recognised so they can add them to the preset).
	"""
	record = {
		"ref_no": "",
		"date": None,
		"counterparty": "",
		"contr_voen": "",
		"contr_iban": "",
		"amount": 0.0,
		"currency": "",
		"drcr": "",
		"purpose": "",
		"description": "",
	}

	debit_val = None
	credit_val = None
	amount_val = None
	direction_val = None
	saw_date_input = False

	for i, std in idx_to_standard.items():
		if i >= len(raw_row):
			continue
		cell = raw_row[i]
		if cell is None:
			continue

		if std == "date":
			saw_date_input = saw_date_input or (str(cell).strip() != "")
			record["date"] = _parse_date(cell, custom_format)
		elif std == "reference_number":
			record["ref_no"] = str(cell).strip()
		elif std == "counterparty":
			record["counterparty"] = str(cell).strip()
		elif std == "counterparty_tax_id":
			record["contr_voen"] = str(cell).strip()
		elif std == "counterparty_iban":
			record["contr_iban"] = str(cell).strip()
		elif std == "amount":
			amount_val = _parse_amount(cell)
		elif std == "debit":
			debit_val = _parse_amount(cell)
		elif std == "credit":
			credit_val = _parse_amount(cell)
		elif std == "direction":
			direction_val = str(cell).strip().upper()
		elif std == "currency":
			record["currency"] = str(cell).strip()
		elif std == "purpose":
			record["purpose"] = str(cell).strip()
		elif std == "description":
			record["description"] = str(cell).strip()

	if amount_mode == "Separate debit/credit columns":
		if (debit_val or 0) > 0:
			record["amount"] = abs(flt(debit_val))
			record["drcr"] = "D"
		elif (credit_val or 0) > 0:
			record["amount"] = abs(flt(credit_val))
			record["drcr"] = "C"
		else:
			return None, "amount", None
	elif amount_mode == "Single column with sign":
		if amount_val is None:
			return None, "amount", None
		record["amount"] = abs(flt(amount_val))
		record["drcr"] = "D" if amount_val < 0 else "C"
	elif amount_mode == "Single column + direction column":
		if amount_val is None or direction_val is None:
			return None, "amount", None
		record["amount"] = abs(flt(amount_val))
		if debit_aliases and direction_val in debit_aliases:
			record["drcr"] = "D"
		elif credit_aliases and direction_val in credit_aliases:
			record["drcr"] = "C"
		else:
			# Unknown direction value — surface to the user so they can add it
			# to the preset, instead of silently guessing.
			return None, "direction", direction_val

	if not record["date"]:
		# Distinguish "row had a date input we couldn't parse" from "row had no date
		# input at all" — only the former is a user-actionable error.
		return None, "date" if saw_date_input else "amount", None
	if record["amount"] == 0:
		return None, "amount", None

	return record, "", None


def _parse_date(value, custom_format):
	if isinstance(value, datetime):
		return value.date().isoformat()
	if isinstance(value, date):
		return value.isoformat()
	if isinstance(value, (int, float)):
		try:
			from openpyxl.utils.datetime import from_excel
			return from_excel(value).date().isoformat()
		except Exception:
			return None
	s = str(value).strip()
	if not s:
		return None

	if custom_format:
		try:
			return datetime.strptime(s, custom_format).date().isoformat()
		except ValueError:
			return None

	for fmt in _COMMON_DATE_FORMATS:
		try:
			return datetime.strptime(s, fmt).date().isoformat()
		except ValueError:
			continue
	return None


@frappe.whitelist()
def auto_detect_column_mappings(file_url, header_row=1):
	"""Read sample file headers and propose a mapping to Standard Fields.

	Returns:
	    {success: True, mappings: [{excel_column, standard_field, confidence}],
	     unmatched: [...header strings...]}
	"""
	from frappe.utils import cint

	header_row = max(cint(header_row) or 1, 1)
	if not file_url:
		return {"success": False, "message": "file_url is required"}

	headers_result = parse_sample_headers(file_url, header_row=header_row)
	if not headers_result.get("success"):
		return headers_result

	from jey_erp.bank_integration.header_synonyms import guess_standard_field

	headers = headers_result.get("headers") or []
	mappings = []
	unmatched = []
	# Standard fields used so far — avoid mapping two columns to the same field.
	# (Debit + Credit are siblings, so we drop them from the "used" set after
	# the loop so both can be picked up.)
	used = set()
	for h in headers:
		guess = guess_standard_field(h)
		if guess and guess not in used:
			mappings.append({"excel_column": h, "standard_field": guess})
			# Date / Reference / Amount / etc. should appear only once; Debit
			# and Credit are mutually exclusive halves of a row so they don't
			# collide with each other, but we still don't want two Debit columns.
			used.add(guess)
		else:
			mappings.append({"excel_column": h, "standard_field": ""})
			unmatched.append(h)

	return {
		"success": True,
		"mappings": mappings,
		"unmatched": unmatched,
		"total_headers": len(headers),
		"matched_count": sum(1 for m in mappings if m["standard_field"]),
	}


@frappe.whitelist()
def parse_sample_preview(file_url, header_row=1, max_rows=20):
	"""Return the header row + first N data rows of a sample file for preview UI."""
	from openpyxl import load_workbook
	from frappe.utils import cint

	header_row = max(cint(header_row) or 1, 1)
	max_rows = max(min(cint(max_rows) or 20, 100), 1)

	if not file_url:
		frappe.throw("file_url is required")

	file_doc = frappe.get_doc("File", {"file_url": file_url})
	if file_doc.is_private and file_doc.owner != frappe.session.user:
		if not frappe.has_permission("File", "read", file_doc.name):
			frappe.throw("Not permitted to read this file")

	file_path = file_doc.get_full_path()
	wb = load_workbook(file_path, data_only=True, read_only=True)
	try:
		ws = wb.active
		# Cap how much we pull: header_row + max_rows worth of cells.
		end_row = header_row + max_rows
		all_rows = list(ws.iter_rows(min_row=1, max_row=end_row, values_only=True))
		if not all_rows or header_row > len(all_rows):
			return {"success": True, "headers": [], "rows": [], "header_row": header_row}

		raw_headers = all_rows[header_row - 1]
		headers = []
		col_indexes = []
		for i, cell in enumerate(raw_headers):
			if cell is None:
				continue
			h = str(cell).strip()
			if not h:
				continue
			headers.append(h)
			col_indexes.append(i)

		rows = []
		for raw in all_rows[header_row:]:
			if not raw or all(c is None or str(c).strip() == "" for c in raw):
				continue
			out = []
			for i in col_indexes:
				cell = raw[i] if i < len(raw) else None
				out.append("" if cell is None else str(cell))
			rows.append(out)
			if len(rows) >= max_rows:
				break

		return {"success": True, "headers": headers, "rows": rows, "header_row": header_row}
	finally:
		wb.close()


@frappe.whitelist()
def parse_sample_headers(file_url, header_row=1):
	"""Read just the header row of a sample file and return cleaned header strings.

	Used by the Bank Integration File Format tab to populate the Excel Column
	dropdown without parsing the whole file.
	"""
	from openpyxl import load_workbook
	from frappe.utils import cint

	header_row = max(cint(header_row) or 1, 1)

	if not file_url:
		frappe.throw("file_url is required")

	file_doc = frappe.get_doc("File", {"file_url": file_url})
	# Restrict to private files owned by the caller or files they otherwise have
	# read access to — public attachments are always allowed.
	if file_doc.is_private and file_doc.owner != frappe.session.user:
		if not frappe.has_permission("File", "read", file_doc.name):
			frappe.throw("Not permitted to read this file")

	file_path = file_doc.get_full_path()
	wb = load_workbook(file_path, data_only=True, read_only=True)
	try:
		ws = wb.active
		all_rows = list(ws.iter_rows(min_row=header_row, max_row=header_row, values_only=True))
		if not all_rows:
			return {"success": True, "headers": []}
		headers = []
		seen = set()
		for cell in all_rows[0]:
			if cell is None:
				continue
			h = str(cell).strip()
			if not h:
				continue
			if h in seen:
				continue
			seen.add(h)
			headers.append(h)
		return {"success": True, "headers": headers}
	finally:
		wb.close()
