"""Diff-based sync for master data shipped as JSON.

The legacy approach (fixtures hook) re-imports every record on every `bench
migrate` via DELETE+INSERT. For static reference data with thousands of rows
that's a multi-minute hit. This module instead reconciles the DB against the
JSON on every migrate: it reads the current rows in one query and applies only
the delta — insert new rows, update changed rows, delete rows missing from the
file (or disable / skip when they are referenced by existing documents).

The diff is intentionally driven by the *DB state*, not a stored file hash:
if rows go missing from the table (manual delete, restore from an old dump),
the next migrate restores them. A full diff costs one SELECT plus an in-memory
comparison — sub-second even for ~13k rows.
"""

from __future__ import annotations

import json
import os
from collections.abc import Callable, Iterable

import frappe


def _load_records(path: str, key_field: str) -> dict[str, dict]:
    with open(path, encoding="utf-8") as f:
        raw = json.load(f)
    if not isinstance(raw, list):
        frappe.throw(f"{path}: expected a JSON array")

    out: dict[str, dict] = {}
    for rec in raw:
        key = rec.get(key_field)
        if not key:
            continue
        out[str(key)] = rec
    return out


def _fetch_existing(doctype: str, fields: Iterable[str], key_field: str) -> dict[str, dict]:
    rows = frappe.db.sql(
        f"SELECT {', '.join('`' + f + '`' for f in fields)} FROM `tab{doctype}`",
        as_dict=True,
    )
    return {str(row[key_field]): row for row in rows}


def _try_remove(doctype: str, name: str, has_disabled: bool) -> str:
    """Smart-delete: try hard delete; on link conflicts soft-disable or skip.

    Returns one of: "deleted", "disabled", "skipped".
    """
    try:
        frappe.delete_doc(
            doctype,
            name,
            ignore_permissions=True,
            ignore_missing=True,
            delete_permanently=True,
        )
        return "deleted"
    except frappe.LinkExistsError:
        if has_disabled:
            frappe.db.set_value(doctype, name, "disabled", 1, update_modified=False)
            return "disabled"
        frappe.logger().warning(
            f"[taxes_az] {doctype} '{name}' is referenced; "
            f"cannot delete and no 'disabled' field — kept as-is"
        )
        return "skipped"


def sync_master_data(
    doctype: str,
    json_path: str,
    key_field: str = "name",
    *,
    is_tree: bool = False,
    manage_deletes: bool = True,
    transform: Callable[[dict], dict] | None = None,
) -> dict:
    """Reconcile a doctype against a JSON file, applying only the delta.

    Runs a full diff against the current DB state every call — no file-hash
    fast-path, so rows that go missing from the table are always restored.

    is_tree: doctype is a nested set — insert via db_insert() and rebuild
        lft/rgt once at the end (avoids per-row tree recomputation and the
        need to insert parents before children).
    manage_deletes: when False, rows present in the DB but not in the JSON
        are left untouched. Use False when the JSON is only a subset of the
        table (e.g. Item Group also holds ERPNext and user-created groups).
    transform: optional callback applied in-place to every loaded record
        before the diff. Use it to rewrite site-dependent references (e.g.
        remap a localized Item Group tree root) so both the insert and the
        update-diff see the resolved values.
    """
    if not os.path.exists(json_path):
        frappe.logger().warning(f"[taxes_az] master data file not found: {json_path}")
        return {"skipped": True}

    target = _load_records(json_path, key_field)
    if not target:
        return {"empty_file": True}

    if transform is not None:
        for key, rec in target.items():
            target[key] = transform(rec)

    meta = frappe.get_meta(doctype)
    # value-bearing fields actually present in the JSON, minus the key itself
    sample = next(iter(target.values()))
    value_fields = [
        f for f in sample.keys()
        if f != key_field and (meta.has_field(f) or f == "name")
    ]
    select_fields = [key_field, *value_fields] if key_field not in value_fields else value_fields
    existing = _fetch_existing(doctype, select_fields, key_field)

    has_disabled = meta.has_field("disabled")

    inserted = updated = deleted = disabled = skipped = 0

    to_insert = [(key, rec) for key, rec in target.items() if key not in existing]
    to_check = [(key, rec) for key, rec in target.items() if key in existing]

    # Bulk-insert path: avoids thousands of ORM .insert() round-trips, and for
    # tree doctypes also sidesteps per-row nested-set recomputation (lft/rgt are
    # rebuilt once at the end). db_insert() skips before_insert/after_insert
    # hooks — appropriate for static master data.
    BULK_THRESHOLD = 100
    use_bulk = is_tree or len(to_insert) >= BULK_THRESHOLD
    if use_bulk:
        for key, rec in to_insert:
            doc = frappe.new_doc(doctype)
            if key_field == "name":
                doc.name = key
                doc.flags.name_set = True
            else:
                doc.set(key_field, key)
            for field in value_fields:
                if field in rec:
                    doc.set(field, rec[field])
            if is_tree:
                # placeholder bounds; rebuild_tree() fixes them after all inserts
                doc.lft = 0
                doc.rgt = 0
            doc.db_insert()
            inserted += 1
            if inserted % 500 == 0:
                frappe.db.commit()
        frappe.db.commit()
    else:
        for key, rec in to_insert:
            doc = frappe.new_doc(doctype)
            if key_field == "name":
                doc.name = key
            else:
                doc.set(key_field, key)
            for field in value_fields:
                if field in rec:
                    doc.set(field, rec[field])
            doc.flags.ignore_permissions = True
            doc.flags.ignore_mandatory = True
            doc.flags.ignore_links = True
            doc.insert(ignore_if_duplicate=True)
            inserted += 1

    for key, rec in to_check:
        db_row = existing[key]
        diff = {f: rec[f] for f in value_fields if f in rec and (db_row.get(f) or "") != (rec[f] or "")}
        if diff:
            frappe.db.set_value(doctype, key, diff, update_modified=False)
            updated += 1

    # Delete rows not present in the JSON. Skipped when the JSON is only a
    # subset of the table.
    if manage_deletes:
        for key in set(existing.keys()) - set(target.keys()):
            outcome = _try_remove(doctype, key, has_disabled)
            if outcome == "deleted":
                deleted += 1
            elif outcome == "disabled":
                disabled += 1
            else:
                skipped += 1

    # Tree doctypes: recompute lft/rgt once for the whole tree.
    if is_tree and (inserted or updated or deleted):
        from frappe.utils.nestedset import rebuild_tree

        rebuild_tree(doctype)

    frappe.db.commit()

    summary = {
        "doctype": doctype,
        "inserted": inserted,
        "updated": updated,
        "deleted": deleted,
        "disabled": disabled,
        "skipped": skipped,
    }
    if inserted or updated or deleted or disabled or skipped:
        print(
            f"[taxes_az] {doctype}: "
            f"+{inserted} ~{updated} -{deleted} (disabled {disabled}, skipped {skipped})"
        )
    return summary


def sync_main_type_of_activity() -> dict:
    path = frappe.get_app_path("taxes_az", "master_data", "main_type_of_activity.json")
    return sync_master_data("Main type of activity", path, key_field="name")


# Item Group names that all mean "the tree root". The shipped item.json refers
# to the root by ERPNext's English name, but localized installs rename it.
_ROOT_ALIASES = ("All Item Groups", "Bütün Element Qrupları")


def _resolve_item_group_root() -> str | None:
    """Find this site's Item Group tree root by any of its known names.

    ERPNext ships the root as "All Item Groups", but localized installs rename
    it (e.g. the Azerbaijani "Bütün Element Qrupları"). Try the known names in
    order, then fall back to the structural root (a group with no parent).
    """
    for candidate in _ROOT_ALIASES:
        if frappe.db.exists("Item Group", candidate):
            return candidate
    roots = frappe.get_all(
        "Item Group",
        filters={"is_group": 1, "parent_item_group": ("in", ("", None))},
        pluck="name",
        limit=1,
    )
    return roots[0] if roots else None


def sync_items() -> dict:
    """Create the fuel Items the app ships, resolving the Item Group root name.

    These records used to live in ``fixtures/item.json``, which Frappe's
    ``import_fixtures`` imports blindly on every migrate. That import hard-fails
    and skips the *whole* file on sites whose Item Group root is not literally
    named "All Item Groups" (e.g. the localized "Bütün Element Qrupları"), so the
    items never get created and the reports that rely on them come up empty.

    We import the same records ourselves instead: the root reference is remapped
    to whatever this site actually calls its root, and only *missing* items are
    inserted — existing items (and any report data on them) are never touched.

    Must run after :func:`sync_item_groups` so the "Yanacaq növləri" group exists.
    """
    path = frappe.get_app_path("taxes_az", "master_data", "item.json")
    if not os.path.exists(path):
        frappe.logger().warning(f"[taxes_az] master data file not found: {path}")
        return {"skipped": True}

    with open(path, encoding="utf-8") as f:
        records = json.load(f)

    root = _resolve_item_group_root()
    print(f"[taxes_az] Item: resolved tree root = {root!r}, {len(records)} shipped item(s)")
    inserted = existed = missing_group = failed = 0

    for rec in records:
        name = rec.get("name") or rec.get("item_code")
        if not name:
            continue
        if frappe.db.exists("Item", name):
            existed += 1
            continue

        group = rec.get("item_group")
        # Remap the ERPNext root name to whatever this site calls its root.
        if group in _ROOT_ALIASES and root:
            group = root
        if not group or not frappe.db.exists("Item Group", group):
            print(f"[taxes_az] Item '{name}': Item Group {group!r} not found — SKIPPED")
            missing_group += 1
            continue

        # Isolate each insert in a savepoint so nothing can abort `bench migrate`
        # — on these deployments migrate runs only once (during image build /
        # setup) and never again, so a crash here would permanently leave the
        # site without these items.
        #
        # ignore_links mirrors how Frappe imports fixtures (see
        # frappe/modules/import_file.py: `doc.flags.ignore_links = True`): it
        # skips _validate_links(), which is the *only* thing that fails when a
        # referenced record (e.g. UOM 'Litre') is not loaded yet at build time.
        # validate() itself passes, so the item is created now and its links
        # resolve once the standard data is in place — which it always is in a
        # real running program.
        frappe.db.savepoint("taxes_az_item")
        try:
            doc = frappe.new_doc("Item")
            doc.update(rec)
            doc.item_group = group
            doc.name = name
            doc.flags.name_set = True
            doc.flags.ignore_permissions = True
            doc.flags.ignore_links = True
            doc.insert(ignore_if_duplicate=True)
        except Exception as e:
            frappe.db.rollback(save_point="taxes_az_item")
            frappe.clear_last_message()
            print(f"[taxes_az] Item '{name}': insert FAILED — SKIPPED ({e})")
            failed += 1
            continue
        frappe.db.release_savepoint("taxes_az_item")
        print(f"[taxes_az] Item: created {name!r} ({rec.get('item_name')}) in {group!r}")
        inserted += 1

    if inserted:
        frappe.db.commit()
    print(
        f"[taxes_az] Item: created {inserted}, already existed {existed}, "
        f"skipped (missing group) {missing_group}, failed {failed}"
    )
    return {
        "doctype": "Item",
        "inserted": inserted,
        "existed": existed,
        "missing_group": missing_group,
        "failed": failed,
    }


def sync_item_groups() -> dict:
    """Sync the Azerbaijan tax-specific Item Groups.

    Item Group is a tree doctype shared with ERPNext and user-created groups,
    so the JSON is only a subset: manage_deletes=False keeps everything else
    untouched, and is_tree=True rebuilds lft/rgt after the diff.

    The shipped JSON parents its top-level groups under ERPNext's "All Item
    Groups". On localized sites the root is renamed (e.g. "Bütün Element
    Qrupları"), so we resolve the real root once and remap that parent — this
    keeps the tree properly nested instead of leaving the groups orphaned.
    """
    path = frappe.get_app_path("taxes_az", "master_data", "item_group.json")

    root = _resolve_item_group_root()
    print(f"[taxes_az] Item Group: resolved tree root = {root!r}")

    def _remap_root_parent(rec: dict) -> dict:
        if rec.get("parent_item_group") in _ROOT_ALIASES and root:
            rec["parent_item_group"] = root
        return rec

    result = sync_master_data(
        "Item Group",
        path,
        key_field="name",
        is_tree=True,
        manage_deletes=False,
        transform=_remap_root_parent,
    )
    print(
        f"[taxes_az] Item Group: created {result.get('inserted', 0)}, "
        f"updated {result.get('updated', 0)} "
        f"(root={root!r})"
    )
    return result


def after_setup_wizard(args=None) -> None:
    """Re-run the master-data sync once the setup wizard has completed.

    On image-based deployments `bench migrate` runs only during image build —
    *before* the setup wizard — so ERPNext has not yet created the Item Group
    tree root or the standard UOMs. Groups created at build time are therefore
    parented to a root that does not exist yet (ERPNext names the root with the
    *translated* "All Item Groups", e.g. the Azerbaijani "Bütün Element
    Qrupları"), which leaves them orphaned and invisible in the tree, and the
    fuel items cannot resolve their UOMs.

    The wizard fires ``setup_wizard_complete`` after it has created the root and
    the standard data, so a second sync here reparents the groups under the real
    root and creates any items skipped at build time. Both syncs are idempotent,
    so this is safe on sites where migrate did run after setup too.

    Never raises: a task that throws here would make the whole setup wizard fail
    (process_setup_stages rolls back and re-raises), so any error is logged and
    swallowed — completing setup matters more than this master-data refresh.
    """
    print("[taxes_az] setup_wizard_complete: re-syncing master data")
    try:
        sync_item_groups()
        sync_items()
    except Exception:
        frappe.db.rollback()
        frappe.log_error(title="[taxes_az] after_setup_wizard failed")
        print("[taxes_az] setup_wizard_complete: sync FAILED (logged, setup not blocked)")
