// Runtime for the declaration (ФНО) formula engine.
//
// Formula Editor is a design-time tool: it reads its storage and emits a Client
// Script carrying a plain CONFIG object plus a call into this runtime. Nothing in
// the generated script references the formula_editor app, so it can be removed
// from production machines and declarations keep calculating.
//
// The formula language and CONFIG shape are unchanged from the previous
// generator — the same `formula_storage` / `table_row_formulas_storage` /
// `report_fields_storage` payloads drive it, byte for byte.
//
// What changed is how it runs. The old generated code walked its formulas one by
// one, and each `report(...)` was its own round-trip that re-executed a whole
// Script Report to read a single column — a VAT period change fired 223 requests
// to run 4 distinct reports. Worse, every formula ended with frm.refresh_field(),
// which in Frappe is a full-form refresh (refresh_dependency + refresh_sections +
// refresh_tabs), so a period change also triggered ~236 of those.
//
// Here, a trigger resolves every report reference it needs in ONE batch call, then
// evaluates the formulas, then refreshes each touched table exactly once.

frappe.provide("taxes_az.formula");

(function () {
	const BATCH_METHOD = "taxes_az.formula_runtime.run_reports_batch";

	// A formula with no explicit order runs in the middle of the pack. Table-row
	// formulas have always been ordered as a block at this same default (the old
	// generator's `// order:` comment was only emitted for main-document formulas),
	// so they keep sorting against parent formulas exactly as before.
	const DEFAULT_ORDER = 10;

	// The one predefined trigger the editor can attach. It used to mean "recompute
	// on every form refresh", which re-ran every report each time a declaration was
	// opened. Recalculation is now explicit: change the period, or press the button.
	const REFRESH_TOKENS = ["При открытии документа", "when_document_opens", "document_opens"];

	const MONTHS = {
		Yanvar: 1, Fevral: 2, Mart: 3, Aprel: 4, May: 5, İyun: 6,
		İyul: 7, Avqust: 8, Sentyabr: 9, Oktyabr: 10, Noyabr: 11, Dekabr: 12,
	};

	// ---------------------------------------------------------------- period

	// Resolve the declaration's reporting period to a date range. Quarter (`rüb`)
	// wins over month (`ay`) when both are present, as it always has.
	function period_range(doc) {
		if (doc && doc["rüb"] && doc.il) {
			const match = String(doc["rüb"]).match(/(\d+)/);
			if (!match) return null;

			const starts = { 1: 1, 2: 4, 3: 7, 4: 10 };
			const ends = { 1: 3, 2: 6, 3: 9, 4: 12 };
			const quarter = parseInt(match[1]);
			if (!starts[quarter]) return null;

			return date_range(doc.il, starts[quarter], ends[quarter]);
		}

		if (doc && doc.ay && MONTHS[doc.ay]) {
			return date_range(doc.il, MONTHS[doc.ay], MONTHS[doc.ay]);
		}

		return null;
	}

	function date_range(year, start_month, end_month) {
		year = year || new Date().getFullYear();
		const last_day = new Date(year, end_month, 0).getDate();
		const pad = (n) => String(n).padStart(2, "0");
		return {
			start: `${year}-${pad(start_month)}-01`,
			end: `${year}-${pad(end_month)}-${pad(last_day)}`,
		};
	}

	// Expand the `$...` placeholders the editor stores in a report field's filters.
	// `$DefaultFiscalYear` has never resolved to anything and is passed through
	// verbatim; no report in use reads `fiscal_year`, and inventing a value here
	// would silently change filed figures.
	function resolve_filters(frm, filters) {
		const resolved = {};

		for (const key of Object.keys(filters || {})) {
			const value = filters[key];

			if (typeof value === "string" && value.startsWith("doc.")) {
				const fieldname = value.slice(4);
				if (frm.doc[fieldname] !== undefined) {
					resolved[key] = frm.doc[fieldname];
					continue;
				}
			}

			if (value === "$DefaultCompany") {
				resolved[key] = frm.doc.company || frappe.defaults.get_user_default("Company");
				continue;
			}

			if (value === "$AyIlSpecial") {
				const range = period_range(frm.doc);
				if (!range) {
					resolved[key] = frappe.datetime.get_today();
				} else if (key === "to_date") {
					resolved[key] = range.end;
				} else {
					resolved[key] = range.start;
				}
				continue;
			}

			resolved[key] = value;
		}

		return resolved;
	}

	// ------------------------------------------------------------ compilation

	// Turn a formula into JavaScript. The substitution order matters and is kept
	// as-is: report() first, then table(), then univ(), then the table-prefixed
	// sum()/val() forms, and only then the bare val()/row(). The prefixed pass has
	// to run before the bare one, or `val(mytable_amount)` would be read as a
	// main-document field instead of a column sum.
	//
	// Report references are compiled to placeholders rather than values, so the
	// result depends only on the formula text and can be cached across periods —
	// the actual figures are substituted per run.
	function compile(cfg, tables, formula, in_row, table_name) {
		const cache_key = `${formula} ${in_row} ${table_name}`;
		if (cfg.__compiled[cache_key]) return cfg.__compiled[cache_key];

		const report_calls = [];
		let code = formula;

		const missing = [];

		code = code.replace(/report\(([^)]+)\)/g, (full, name) => {
			// Matched against the raw capture, spaces and all — some stored report
			// names genuinely carry a leading space, and trimming would repoint them.
			const field_id = cfg.__reports_by_name[name];
			if (!field_id) {
				// The formula names a report field that does not exist. Keep the code
				// evaluable, but remember it: the formula must not be applied, because a
				// silent 0 here is indistinguishable from a real 0.
				missing.push(name);
				return "0";
			}
			const placeholder = `__RP${report_calls.length}__`;
			report_calls.push({ placeholder: placeholder, field_id: field_id });
			return placeholder;
		});

		code = code.replace(/table\(([^,)]+),\s*([^,)]+),\s*([^)]+)\)/g, (m, tbl, row_no, fld) => {
			const t = JSON.stringify(tbl.trim().replace(/['"]/g, ""));
			const f = JSON.stringify(fld.trim().replace(/['"]/g, ""));
			const n = row_no.trim();
			return `(function(){try{const rows=frm.doc[${t}];const r=rows&&rows[${n}-1];` +
				`return r?(parseFloat(r[${f}])||0):0;}catch(e){return 0;}})()`;
		});

		for (const field_id of Object.keys(cfg.universal_fields || {})) {
			const name = (cfg.universal_fields[field_id] || {}).name;
			if (!name) continue;
			const pattern = new RegExp(`univ\\(${escape_regex(name)}\\)`, "g");
			code = code.replace(
				pattern,
				`(frm.doc.__universalFields && frm.doc.__universalFields[${JSON.stringify(field_id)}] || 0)`
			);
		}

		for (const prefix of tables) {
			const column_sum = (m, col) => {
				const t = JSON.stringify(prefix);
				const c = JSON.stringify(col);
				return `(frm.doc[${t}]&&frm.doc[${t}].reduce((s,r)=>s+(parseFloat(r[${c}])||0),0)||0)`;
			};
			const p = escape_regex(prefix);
			code = code.replace(new RegExp(`sum\\(${p}_([^)]+)\\)`, "g"), column_sum);
			code = code.replace(new RegExp(`val\\(${p}_([^)]+)\\)`, "g"), column_sum);
		}

		if (in_row) {
			code = code.replace(/row\(([^)]+)\)/g, (m, f) => `(parseFloat(row[${JSON.stringify(f)}])||0)`);
			code = code.replace(/val\(([^)]+)\)/g, (m, f) => {
				if (table_name && f.startsWith(table_name + "_")) {
					const col = f.slice(table_name.length + 1);
					return `(parseFloat(row[${JSON.stringify(col)}])||0)`;
				}
				return `(parseFloat(frm.doc[${JSON.stringify(f)}])||0)`;
			});
		} else {
			code = code.replace(/val\(([^)]+)\)/g, (m, f) => `(parseFloat(frm.doc[${JSON.stringify(f)}])||0)`);
			code = code.replace(/row\(([^)]+)\)/g, () => "0");
		}

		const compiled = { code: code, report_calls: report_calls, missing: missing };
		cfg.__compiled[cache_key] = compiled;
		return compiled;
	}

	function escape_regex(s) {
		return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
	}

	// ------------------------------------------------------------------- plan

	// Everything a trigger has to recompute, in the order it must happen — later
	// formulas read fields that earlier ones write, via val()/table().
	//
	// `trigger` of null selects every formula, which is what the recalculate button
	// runs. Selecting by trigger and selecting everything must produce the same
	// relative order, so both go through here.
	function plan_for(cfg, trigger) {
		const wanted = (triggers) => trigger === null || has_trigger(triggers, trigger);
		const ops = [];

		for (const fieldname of Object.keys(cfg.formulas || {})) {
			const info = cfg.formulas[fieldname];
			if (!info || info.is_table_field || !info.formula) continue;
			if (!wanted(info.triggers)) continue;

			ops.push({
				kind: "field",
				fieldname: fieldname,
				formula: info.formula.trim(),
				order: order_of(info.order),
			});
		}

		const rows = Object.keys(cfg.table_row_formulas || {})
			.map((id) => cfg.table_row_formulas[id])
			.filter((f) => f && f.formula && wanted(f.triggers))
			.sort((a, b) => order_of(a.order) - order_of(b.order));

		for (const f of rows) {
			ops.push({
				kind: "row",
				table: f.table_name,
				fieldname: f.field_name,
				// Stored 1-based, as the editor presents it.
				index: parseInt(f.row_index) - 1,
				formula: f.formula,
				// Table-row formulas sort against main-document formulas at the default,
				// while keeping the relative order established just above.
				order: DEFAULT_ORDER,
			});
		}

		// Stable, so equal orders keep the sequence built above.
		return ops
			.map((op, i) => [op, i])
			.sort((a, b) => a[0].order - b[0].order || a[1] - b[1])
			.map((pair) => pair[0]);
	}

	function order_of(value) {
		const n = parseInt(value);
		return isNaN(n) ? DEFAULT_ORDER : n;
	}

	function has_trigger(triggers, trigger) {
		return String(triggers || "")
			.split(",")
			.map((t) => t.trim())
			.includes(trigger);
	}

	function is_refresh_token(trigger) {
		return REFRESH_TOKENS.includes(trigger);
	}

	// Every trigger the config binds a handler to. Refresh tokens are deliberately
	// excluded: opening a declaration no longer recomputes it.
	function triggers_of(cfg) {
		const found = new Set();

		const collect = (triggers) =>
			String(triggers || "")
				.split(",")
				.map((t) => t.trim())
				.filter((t) => t && !is_refresh_token(t))
				.forEach((t) => found.add(t));

		Object.values(cfg.formulas || {}).forEach((f) => f && collect(f.triggers));
		Object.values(cfg.table_row_formulas || {}).forEach((f) => f && collect(f.triggers));

		return [...found];
	}

	// ------------------------------------------------------------------- run

	// Resolve every report reference the plan needs, with one server round-trip.
	// Identical (report, filters, column) references collapse to a single entry, so
	// a report is executed once no matter how many formulas read from it.
	async function fetch_report_values(frm, cfg, plans) {
		const wanted = {};

		for (const { compiled } of plans) {
			for (const call of compiled.report_calls) {
				const field = cfg.report_fields[call.field_id];
				if (!field) continue;

				const filters = resolve_filters(frm, parse_json(field.filters_json));
				const key = JSON.stringify([field.report, sorted(filters), field.field_name]);

				call.key = key;
				wanted[key] = {
					key: key,
					report: field.report,
					filters: filters,
					field_name: field.field_name,
				};
			}
		}

		const requests = Object.values(wanted);
		if (!requests.length) return { values: {}, errors: [] };

		try {
			const r = await frappe.call({
				method: BATCH_METHOD,
				args: { requests: requests },
				freeze: false,
			});
			const message = (r && r.message) || {};
			return { values: message.values || {}, errors: message.errors || [] };
		} catch (e) {
			console.error("taxes_az.formula: report batch failed", e);
			return {
				values: {},
				errors: [{ report: __("Hesabatlar"), message: e.message || String(e) }],
			};
		}
	}

	function parse_json(raw) {
		try {
			return JSON.parse(raw || "{}");
		} catch (e) {
			return {};
		}
	}

	function sorted(obj) {
		return Object.keys(obj)
			.sort()
			.reduce((acc, k) => ((acc[k] = obj[k]), acc), {});
	}

	async function run_plan(frm, cfg, ops) {
		if (!ops.length) return;

		const tables = table_fields(frm);
		const plans = ops.map((op) => ({
			op: op,
			compiled: compile(cfg, tables, op.formula, op.kind === "row", op.table || null),
		}));

		const { values, errors } = await fetch_report_values(frm, cfg, plans);
		const touched = new Set();
		const missing_fields = new Set();
		let skipped = 0;

		for (const { op, compiled } of plans) {
			// A formula whose report could not be resolved is left alone rather than
			// written as 0. An unexplained 0 on a tax return reads as a real figure and
			// gets filed; a field that visibly did not calculate does not.
			const unresolved = compiled.report_calls.some((call) => !(call.key in values));
			if (unresolved || compiled.missing.length) {
				compiled.missing.forEach((name) => missing_fields.add(name));
				skipped++;
				continue;
			}

			let code = compiled.code;
			for (const call of compiled.report_calls) {
				code = code.split(call.placeholder).join(to_number(values[call.key]));
			}

			if (op.kind === "row") {
				apply_to_row(frm, op, code, touched);
			} else {
				apply_to_field(frm, op, code);
			}
		}

		// One refresh per table that actually changed. The old engine called
		// refresh_field() after every single formula, and each of those is a full-form
		// refresh that also re-activates the current tab — which is what made subtab
		// content flash and vanish while a period was being applied.
		touched.forEach((table) => frm.refresh_field(table));

		if (errors.length || missing_fields.size) {
			report_failure(errors, [...missing_fields], skipped);
			return false;
		}

		return true;
	}

	// Tell the user, in the form, that the declaration did not fully calculate. The
	// engine used to swallow every failure and write 0 — a user without report
	// permission got a plausible-looking, fully-zeroed tax return and no hint that
	// anything had gone wrong.
	function report_failure(errors, missing_fields, skipped) {
		const lines = [];

		if (errors.length) {
			lines.push(`<b>${__("Hesabatlar işləmədi")}:</b><ul>`);
			for (const e of errors) {
				lines.push(`<li><b>${frappe.utils.escape_html(e.report)}</b> — ` +
					`${frappe.utils.escape_html(String(e.message))}</li>`);
			}
			lines.push("</ul>");
		}

		if (missing_fields.length) {
			lines.push(`<b>${__("Düsturda mövcud olmayan report-sahə")}:</b><ul>`);
			for (const name of missing_fields) {
				lines.push(`<li><code>${frappe.utils.escape_html(name)}</code></li>`);
			}
			lines.push("</ul>");
		}

		lines.push(
			`<p>${__("Bu səbəbdən {0} düstur hesablanmadı — sahələr 0 ilə DOLDURULMADI.", [skipped])}</p>`
		);
		lines.push(`<p>${__("Bəyannaməni göndərməzdən əvvəl problemi aradan qaldırın.")}</p>`);

		frappe.msgprint({
			title: __("Hesablama tamamlanmadı"),
			indicator: "red",
			message: lines.join(""),
		});
	}

	function apply_to_row(frm, op, code, touched) {
		const rows = frm.doc[op.table];
		const row = rows && rows[op.index];
		if (!row) return;

		const grid = frm.fields_dict[op.table] && frm.fields_dict[op.table].grid;
		if (!grid) return;

		frappe.model.set_value(grid.doctype, row.name, op.fieldname, evaluate(code, row, frm, op));
		touched.add(op.table);
	}

	function apply_to_field(frm, op, code) {
		frm.set_value(op.fieldname, evaluate(code, null, frm, op));
	}

	function evaluate(code, row, frm, op) {
		try {
			const result = new Function("row", "frm", `return ${code};`)(row, frm);
			return isNaN(result) ? 0 : result;
		} catch (e) {
			console.error("taxes_az.formula: hesablama alınmadı", op.formula, e);
			return 0;
		}
	}

	function to_number(value) {
		const n = parseFloat(value);
		if (isNaN(n)) return 0;
		return Math.round(n * 100) / 100;
	}

	function table_fields(frm) {
		return frm.meta.fields.filter((df) => df.fieldtype === "Table").map((df) => df.fieldname);
	}

	// ---------------------------------------------------------------- public

	// Build the frappe.ui.form.on handler map for a generated Client Script.
	taxes_az.formula.handlers = function (cfg) {
		cfg.__compiled = {};
		cfg.__reports_by_name = {};
		for (const id of Object.keys(cfg.report_fields || {})) {
			const field = cfg.report_fields[id];
			if (field && field.name) cfg.__reports_by_name[field.name] = id;
		}

		const handlers = {
			refresh: function (frm) {
				frm.add_custom_button(__("Yenidən hesabla"), () => recalculate_all(frm, cfg));
			},
		};

		for (const trigger of triggers_of(cfg)) {
			handlers[trigger] = function (frm) {
				return run_plan(frm, cfg, plan_for(cfg, trigger));
			};
		}

		return handlers;
	};

	// Recompute the whole declaration on demand: every formula, exactly once, in the
	// same relative order a trigger would run them in.
	function recalculate_all(frm, cfg) {
		frappe.dom.freeze(__("Hesablanır..."));
		run_plan(frm, cfg, plan_for(cfg, null))
			.then((ok) => {
				// Only claim success when it actually succeeded — run_plan has already
				// shown the user what failed.
				if (ok) {
					frappe.show_alert({ message: __("Hesablandı"), indicator: "green" });
				}
			})
			.finally(() => frappe.dom.unfreeze());
	}

	taxes_az.formula.recalculate_all = recalculate_all;
})();
