#!/usr/bin/env python3
"""
GSTAgent Audit Bridge v3.0
Law-First RCM Engine — same principle as Recon Bridge v9.
Forward charge NEVER goes into RCM. Law is supreme.

Fetches comprehensive GST data from TallyPrime for the Audit Engine.
  - Purchase register (ITC) — 37 categories
  - Sales / output register — outward liability
  - GST ledger balances     — debit/credit movements
  - Net GST payable position

Requirements: pip install requests
Run with TallyPrime open.
"""

import tkinter as tk
from tkinter import messagebox, scrolledtext
import threading, requests, json, re, os, xml.etree.ElementTree as ET
from datetime import datetime, date
from pathlib import Path

GSTAGENT_API = "https://gstagent.in"
CONFIG_FILE  = Path.home() / ".gstagent_audit_config.json"

TEAL    = "#0E7A6E"
TEAL2   = "#0A5F55"
BG      = "#F8FAFC"
WHITE   = "#FFFFFF"
BORDER  = "#E2E8F0"
INK     = "#0F172A"
INK2    = "#475569"
INK3    = "#94A3B8"
RED     = "#EF4444"
GREEN   = "#22C55E"
AMBER   = "#F59E0B"
FONT    = ("Segoe UI", 10)
FONT_SM = ("Segoe UI", 9)
FONT_LG = ("Segoe UI", 12, "bold")
MONO    = ("Consolas", 9)

# ── Non-RCM helpers (unchanged) ───────────────────────────────────────────────
BLOCKED_MOTOR    = ["motor vehicle","motor vehicles","car ","suv ","sedan","hatchback","jeep ","van ","scooter","two wheeler","bike ","motorcycle","car purchase","vehicle purchase","automobiles"]
BLOCKED_VESSEL   = ["aircraft","helicopter","vessel ","boat ","ship ","yacht"]
BLOCKED_FOOD     = ["food ","food &","outdoor catering","catering service","canteen","restaurant","hotel meal","refreshment","snacks","beverage","cold drink","staff welfare","employee welfare","entertainment exp"]
BLOCKED_HEALTH   = ["beauty treatment","beauty parlour","salon ","spa ","ayurveda","cosmetic surgery","plastic surgery","health service","clinic ","hospital ","nursing home","health checkup","yoga ","wellness"]
BLOCKED_INS_PERS = ["life insurance","personal accident","personal insurance","term insurance","ulip "]
BLOCKED_INS_OK   = ["group mediclaim","group health","group insurance","esic","esi ","workmen compensation","employees insurance","statutory insurance"]
BLOCKED_CLUB     = ["club membership","club fee","health club","fitness centre","gym ","gymnasium","recreation club","sports club","country club","swimming pool","golf"]
BLOCKED_WORKS    = ["works contract","civil work","civil construction","building construction","building repair","interior work","interior decoration","painting work","flooring ","tiles work","plumbing","electrical wiring","renovation","whitewash","false ceiling","carpentry"]
BLOCKED_PERSONAL = ["personal consumption","personal expense","personal use","proprietor ","partner draw","owner draw","drawings","household","family expense"]
BLOCKED_GIFTS    = ["free sample","gift ","gifts ","donation","sample ","damaged goods","loss of stock","pilferage","shortage","written off","obsolete stock"]

BLOCKED_RULES = [
    (BLOCKED_MOTOR,    "Sec 17(5)(a) Motor vehicle ITC blocked",                "BLOCKED_MOTOR_VEHICLE",  "Cannot claim ITC. Exception: taxi/transport/training business."),
    (BLOCKED_VESSEL,   "Sec 17(5)(aa) Vessel/aircraft ITC blocked",             "BLOCKED_VESSEL_AIRCRAFT","Cannot claim ITC."),
    (BLOCKED_FOOD,     "Sec 17(5)(b) Food/catering/hospitality ITC blocked",    "BLOCKED_FOOD",           "Cannot claim ITC. Expense to P&L."),
    (BLOCKED_HEALTH,   "Sec 17(5)(b) Health/beauty services ITC blocked",       "BLOCKED_HEALTH_BEAUTY",  "Cannot claim ITC. Expense to P&L."),
    (BLOCKED_INS_PERS, "Sec 17(5)(b) Personal insurance ITC blocked",           "BLOCKED_INSURANCE",      "Cannot claim ITC. Note: employer group mediclaim is ELIGIBLE."),
    (BLOCKED_CLUB,     "Sec 17(5)(c) Club/fitness membership ITC blocked",      "BLOCKED_CLUB",           "Cannot claim ITC."),
    (BLOCKED_WORKS,    "Sec 17(5)(e) Works contract for immovable property",    "BLOCKED_WORKS_CONTRACT", "Cannot claim ITC. Exception: plant and machinery."),
    (BLOCKED_PERSONAL, "Sec 17(5)(i) Personal consumption ITC blocked",         "BLOCKED_PERSONAL",       "Cannot claim ITC."),
    (BLOCKED_GIFTS,    "Sec 17(5)(j) Gifts/samples/lost goods ITC blocked",     "BLOCKED_LOSS_GIFT",      "Cannot claim ITC. Reverse if already claimed."),
]

CAPITAL_KW = ["plant and machinery","plant & machinery","machinery purchase","machine purchase","equipment purchase","fixed asset","capital goods","fixed assets","computer purchase","laptop purchase","desktop purchase","server purchase","printer purchase","scanner purchase","photocopier","furniture purchase","furniture & fixture","fixture","office furniture","air conditioner","ac purchase","generator purchase","ups ","inverter purchase","lab equipment","testing equipment","capital work","capital expenditure","capex","manufacturing equipment","production equipment","fork lift","crane ","lathe ","cnc machine","compressor","boiler ","cooling tower","solar panel","capital addition"]
IMPORT_KW  = ["bill of entry","boe ","custom duty","cvd ","import duty","customs duty","customs clearance","imported goods","import purchase","ocean freight import","air freight import"]
ISD_KW     = ["isd ","input service distributor","hq allocation","head office credit","ho itc","corporate allocation"]
JOB_KW     = ["job work","job charge","processing charge","conversion charge","job worker","contract processing","toll charge","fabrication charge","finishing charge"]
SEZ_KW     = ["sez ","special economic zone","sez unit","export packing","shipping bill"]
COMP_KW    = ["composition dealer","composition scheme","under composition","comp dealer"]
REL_KW     = ["related party","group company","associate company","subsidiary","holding company","sister concern","affiliated","joint venture"]
ELG_PROF   = ["ca fee","chartered accountant","audit fee","statutory audit","consultant fee","consultancy","management consultant","architect fee","engineer fee","technical consultant","it consultant","valuation fee","project management"]
ELG_TELE   = ["telephone bill","mobile bill","internet bill","broadband","mobile recharge","jio ","airtel","vodafone","vi ","bsnl","data card","wifi ","telecom"]
ELG_BANK   = ["bank charge","bank commission","processing fee","loan processing","dd charge","neft charge","rtgs charge","locker rent","bank guarantee","lc charge"]

def kw(text, words):
    t = text.lower()
    return any(w in t for w in words)

def pa(v):
    try: return abs(float(str(v or 0).replace(",","")))
    except: return 0.0

def spa(v):
    try: return float(str(v or 0).replace(",",""))
    except: return 0.0

def fmt_date(d):
    d = (d or "").strip()
    if len(d)==8 and d.isdigit():
        return f"{d[6:8]}-{d[4:6]}-{d[:4]}"
    return d

def load_config():
    try:
        if CONFIG_FILE.exists():
            return json.loads(CONFIG_FILE.read_text())
    except: pass
    return {}

def save_config(cfg):
    try: CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
    except: pass

def tally_post(xml: str, port: str) -> str:
    r = requests.post(f"http://localhost:{port}", data=xml.encode("utf-8"),
                      timeout=30, headers={"Content-Type": "application/xml"})
    return r.text

def check_alive(port: str) -> str:
    try:
        xml = ("<ENVELOPE><HEADER><TALLYREQUEST>Export Data</TALLYREQUEST></HEADER>"
               "<BODY><EXPORTDATA><REQUESTDESC>"
               "<REPORTNAME>List of Companies</REPORTNAME>"
               "<STATICVARIABLES><SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT></STATICVARIABLES>"
               "</REQUESTDESC></EXPORTDATA></BODY></ENVELOPE>")
        raw  = tally_post(xml, port)
        root = ET.fromstring(raw)
        names = [c.get("NAME", "") for c in root.iter("COMPANY") if c.get("NAME")]
        return names[0] if names else "Connected"
    except:
        return ""


# ==============================================================================
# LAW-FIRST RCM ENGINE (v3) — same as Recon Bridge v9
# ==============================================================================
# ══════════════════════════════════════════════════════════════════════════════
# LAW-FIRST RCM ENGINE — same as Recon Bridge v9
# Forward charge NEVER goes into RCM. Law is supreme.
# ══════════════════════════════════════════════════════════════════════════════

# Sec 9(3) — Mandatory RCM (hard flag when missed)
SEC_9_3_CATS = {
    "RCM_GTA","RCM_LEGAL","RCM_DIRECTOR","RCM_INSURANCE_AGENT",
    "RCM_RECOVERY","RCM_AUTHOR_ARTIST","RCM_SECURITY","RCM_RENT_VEHICLE",
    "RCM_SPONSORSHIP","RCM_ARBITRATION","RCM_IMPORT_SERVICE",
    "RCM_GOODS_NOTIFIED","RCM_DSA","RCM_LONG_LEASE","RCM_OIDAR",
}
# Sec 9(4) — Conditional RCM (soft flag, only if no GSTIN)
SEC_9_4_CATS = {"RCM_RENT_COMMERCIAL"}
# ITC blocked for director
NO_ITC_CATS  = {"RCM_DIRECTOR"}

SEC_9_3_RULES = [
    {
        "cat": "RCM_GTA", "rate": "5% or 12%", "tds": ["C"],
        "section": "Sec 9(3) Entry 1 — Goods Transport Agency",
        "action": "Pay RCM in GSTR-3B Table 3.1(d). Obtain GTA declaration for rate.",
        "itc_eligible": True,
        "keywords": [
            "goods transport","gta ","road transport","lorry","truck freight",
            "freight inward","freight & cartage","freight charges","cartage",
            "transport charges","forwarding charges","freight paid","freight expense",
            "freight outward","cartage paid","delivery charges","dispatch charges",
            "loading charges","unloading charges","packing & forwarding",
            "c&f charges","octroi","transit charges","haulage","tempo service",
            "tempo charges","tempo hire","vikram service","tata ace","mini truck",
            "packers","movers","packers & movers","transport agency","logistics charges",
        ],
        "ledger_regex": r"(freight|cartage|carriage|lorry|truck|\bgta\b|goods.*transport|\btransport\b|tempo|haulage|forwarding|logistics|dispatch|loading|unloading|courier|packers|movers|vehicle.*freight)",
    },
    {
        "cat": "RCM_LEGAL", "rate": "18%", "tds": ["J"],
        "section": "Sec 9(3) Entry 2 — Legal Services by Advocate",
        "action": "Pay RCM @ 18%. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": [
            "legal service","advocate ","vakil ","lawyer fee","legal fee",
            "legal charge","legal retainer","solicitor","law firm",
            "legal & professional","legal expenses","legal charges",
            "professional charges","professional fees","consultation charges",
            "advisory charges","legal advisory","legal opinion","court charges",
            "litigation expense","notary charges","legal documentation",
            "retainer fee","retainership","legal cons","legal associates",
            "& associates","law office","law chamber","counsel fee","legal counsel",
            "llb ","barrister","junior counsel","senior counsel","legal advisor",
        ],
        "ledger_regex": r"(legal.*fee|legal.*charge|advocate|lawyer|solicitor|legal.*service|professional.*fee|professional.*charge|retainer|legal.*consul|law.*firm|law.*office|counsel|llb|barrister)",
    },
    {
        "cat": "RCM_DIRECTOR", "rate": "18%", "tds": ["J"],
        "section": "Sec 9(3) Entry 6 — Director Remuneration",
        "action": "Pay RCM @ 18%. DO NOT claim ITC — blocked under Sec 17(5)(g).",
        "itc_eligible": False,
        "keywords": [
            "director fee","director remuneration","director sitting fee",
            "directors fee","board fee","sitting fee","managerial remuneration",
            "director salary","director commission","md remuneration",
            "cmd remuneration","director reimbursement","board meeting fee",
        ],
        "ledger_regex": r"(director.*fee|director.*remun|sitting.*fee|board.*fee|managerial.*remun|director.*salary|director.*commission)",
    },
    {
        "cat": "RCM_SECURITY", "rate": "18%", "tds": ["C"],
        "section": "Sec 9(3) Entry 14 — Security Services",
        "action": "Pay RCM @ 18% if supplier is unregistered body corporate. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": [
            "security service","security guard","security agency","watchman ",
            "guard service","security charges","security expense","housekeeping",
            "facility management","security personnel","security staff",
            "manpower charges","contract labour","outsourced staff","agency charges",
        ],
        "ledger_regex": r"(security.*service|security.*charge|guard.*charge|watchman|housekeeping.*charge|facility.*management|security.*agency|manpower.*charge)",
    },
    {
        "cat": "RCM_SPONSORSHIP", "rate": "18%", "tds": [],
        "section": "Sec 9(3) Entry 10 — Sponsorship Services",
        "action": "Pay RCM @ 18%.",
        "itc_eligible": True,
        "keywords": ["sponsorship","brand ambassador","event sponsorship","promotion rights"],
        "ledger_regex": r"(sponsorship|event.*sponsor|brand.*sponsor)",
    },
    {
        "cat": "RCM_ARBITRATION", "rate": "18%", "tds": [],
        "section": "Sec 9(3) Entry 3 — Arbitral Tribunal",
        "action": "Pay RCM @ 18%. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": ["arbitral tribunal","arbitration fee","arbitrator fee","conciliation fee"],
        "ledger_regex": r"(arbitrat|tribunal|conciliat)",
    },
    {
        "cat": "RCM_INSURANCE_AGENT", "rate": "18%", "tds": [],
        "section": "Sec 9(3) Entry 7 — Insurance Agent",
        "action": "Pay RCM @ 18%. Only if recipient is an insurance company.",
        "itc_eligible": True,
        "keywords": ["insurance agent","insurance commission","insurance broker","actuarial service"],
        "ledger_regex": r"(insurance.*commission|insurance.*brokerage|insurance.*agent|actuarial)",
    },
    {
        "cat": "RCM_RECOVERY", "rate": "18%", "tds": [],
        "section": "Sec 9(3) Entry 8 — Recovery Agent",
        "action": "Pay RCM @ 18%. Only if recipient is bank/NBFC/FI.",
        "itc_eligible": True,
        "keywords": ["recovery agent","debt recovery","loan recovery","collection agent","debt collection"],
        "ledger_regex": r"(recovery.*agent|debt.*recover|collection.*agent|loan.*recover)",
    },
    {
        "cat": "RCM_AUTHOR_ARTIST", "rate": "12% or 18%", "tds": ["J"],
        "section": "Sec 9(3) Entry 9 — Author/Artist/Photographer",
        "action": "Pay RCM if supplier is unregistered individual. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": [
            "author ","musician ","composer ","lyricist","photographer",
            "content writer","artist fee","creative fee","copyright fee",
            "royalty ","model fee","designer fee","freelance","content rights",
        ],
        "ledger_regex": r"(royalty|copyright|author.*fee|artist.*fee|musician|composer|lyricist|photographer.*fee|content.*right)",
    },
    {
        "cat": "RCM_RENT_VEHICLE", "rate": "5%", "tds": ["I"],
        "section": "Sec 9(3) Entry 15 — Renting Motor Vehicle",
        "action": "Pay RCM @ 5% if supplier is unregistered individual. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": [
            "vehicle on hire","car on hire","cab on hire","vehicle hire",
            "car hire","vehicle rental","hired vehicle","bus hire","van hire",
            "staff vehicle hire","rent a cab","cab charges",
        ],
        "ledger_regex": r"(vehicle.*hire|car.*hire|cab.*hire|rent.*a.*cab|motor.*vehicle.*hire|staff.*cab|vehicle.*rental)",
    },
    {
        "cat": "RCM_OIDAR", "rate": "IGST", "tds": [],
        "section": "IGST Act — OIDAR / Import of Services",
        "action": "Pay IGST as RCM. Check if supplier now GST registered in India. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": [
            "google workspace","aws ","microsoft azure","azure ","digital ocean",
            "cloudflare","zoom ","slack ","dropbox","adobe ","saas ","cloud service",
            "cloud storage","hosting ","web hosting","domain ","facebook ads",
            "meta ads","google ads","software subscription","online subscription",
            "subscription charges","cloud charges","platform fees",
            "import of service","foreign consultant","overseas consultant",
            "foreign technical","technical knowhow","royalty to foreign",
            "foreign service","foreign vendor","overseas vendor",
            "international service","offshore service","foreign professional",
        ],
        "ledger_regex": r"(cloud|hosting|saas|aws|azure|google.*workspace|online.*subscription|software.*subscription|platform.*fee|import.*service|foreign.*service|overseas.*service|offshore.*service|foreign.*consul|international.*service)",
    },
    {
        "cat": "RCM_DSA", "rate": "18%", "tds": [],
        "section": "Sec 9(3) — DSA/Business Facilitator",
        "action": "Pay RCM if recipient is bank/NBFC. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": ["direct selling agent","dsa commission","dsa fee","channel partner","loan agent","sourcing agent","business facilitator"],
        "ledger_regex": r"(dsa.*commission|dsa.*fee|direct.*selling.*agent|business.*facilitator|channel.*partner|sourcing.*agent)",
    },
    {
        "cat": "RCM_LONG_LEASE", "rate": "18%", "tds": [],
        "section": "Sec 9(3) — Long Term Land Lease",
        "action": "Pay RCM on upfront lease premium. Claim ITC after payment.",
        "itc_eligible": True,
        "keywords": ["long term lease","99 year lease","leasehold land","development rights","tdr "],
        "ledger_regex": r"(long.*term.*lease|leasehold.*land|development.*right|tdr\b|99.*year.*lease)",
    },
    {
        "cat": "RCM_GOODS_NOTIFIED", "rate": "Varies", "tds": [],
        "section": "Sec 9(3) — Notified Goods",
        "action": "Verify item is in the notified goods list. Pay RCM at applicable rate.",
        "itc_eligible": True,
        "keywords": [
            "cashew","raw cashew","cashew nut","tobacco leaves","tobacco leaf",
            "silk yarn","raw silk","mulberry silk","metal scrap","iron scrap",
            "steel scrap","used vehicle","old vehicle","waste paper",
        ],
        "ledger_regex": r"(cashew|tobacco.*leaf|silk.*yarn|raw.*silk|metal.*scrap|iron.*scrap|steel.*scrap|waste.*paper)",
    },
]

SEC_9_4_RULES = [
    {
        "cat": "RCM_RENT_COMMERCIAL", "rate": "18%", "tds": ["I"],
        "section": "Sec 9(4) + Notification 05/2022 — Commercial Rent from Unregistered Landlord",
        "action": "Verify landlord GST registration. If unregistered, pay RCM @ 18% since Jul 2022. Claim ITC after payment.",
        "itc_eligible": True,
        "requires_unregistered": True,
        "keywords": [
            "rent paid","rent payable","rent of building","rent of property",
            "rent for property","rent for building","rent for premises",
            "rent for office","rent for shop","rent for godown","rent for warehouse",
            "rent for factory","rent for flat","rent for space","rent for land",
            "office rent","shop rent","godown rent","warehouse rent","factory rent",
            "commercial rent","property rent","building rent","premises rent",
            "showroom rent","lease rent","rent expense","rent & taxes",
            "rental charges","rent a/c","rent account","rent payable",
            "rent accrued","rent outstanding","monthly rent","annual rent",
            "quarterly rent","tenancy charges","accommodation charges",
            "space charges","area rent","land rent","site rent","plot rent",
            "stall rent","kiosk rent","rental expense","rent cost",
        ],
        "ledger_regex": r"(\brent\b|\brent\s|\srental\b|tenancy|lease.*rent|rent.*paid|rent.*payable|rent.*building|rent.*property|rent.*premises|rent.*office|rent.*shop|rent.*godown|rent.*warehouse|rent.*factory|rent.*land|rent.*space|rent.*site|rent.*plot|monthly.*rent|annual.*rent|quarterly.*rent)",
    },
]

TDS_TO_RCM_CAT = {'I': 'RCM_RENT_COMMERCIAL', 'C': 'RCM_GTA', 'J': 'RCM_LEGAL', 'H': 'RCM_OTHER'}


def read_ledger_signals_audit(ledgers_with_amounts):
    """
    Read 5 universal RCM signals from voucher ledger entries.
    ledgers_with_amounts: list of (name, signed_amount)
    Returns signal dict.
    """
    rcm_has_input  = False
    rcm_has_output = False
    rcm_igst = rcm_cgst = rcm_sgst = 0.0
    fwd_igst = fwd_cgst = fwd_sgst = 0.0
    expense_debit  = 0.0
    creditor_name  = ""
    tds_section    = None
    BANK_KW = ["hdfc","icici","sbi ","axis bank","kotak","pnb ","current account",
               "savings account","petty cash","cash account","bank of baroda",
               "yes bank","indusind","rbl bank"]

    for name, sa in ledgers_with_amounts:
        nl = name.lower()
        a  = abs(sa)
        has_rcm = "rcm" in nl or "reverse charge" in nl

        # Signal 1: RCM INPUT
        if has_rcm and any(x in nl for x in ["input","itc","credit","receivable"]):
            rcm_has_input = True
            if any(x in nl for x in ["igst","integrated"]): rcm_igst += a
            elif any(x in nl for x in ["cgst","central"]):  rcm_cgst += a
            elif any(x in nl for x in ["sgst","state","utgst"]): rcm_sgst += a
            continue

        # Signal 2: RCM OUTPUT
        if has_rcm and any(x in nl for x in ["output","payable","liability"]):
            rcm_has_output = True
            continue

        # Forward charge GST
        is_gst = any(x in nl for x in ["igst","cgst","sgst","utgst","integrated gst",
                                        "central gst","state gst","input tax credit",
                                        "input credit","tax credit","gst credit"])
        is_output_side = any(x in nl for x in ["output","payable","liability"])
        if is_gst:
            if not is_output_side:
                if any(x in nl for x in ["igst","integrated gst"]): fwd_igst += a
                elif any(x in nl for x in ["cgst","central gst"]): fwd_cgst += a
                elif any(x in nl for x in ["sgst","state gst","utgst"]): fwd_sgst += a
            continue

        # Signal 5: TDS section
        if any(x in nl for x in ["tds","tcs"]):
            if not tds_section:
                m = re.search(r'194\s?-?\s?([a-zA-Z])\b', nl)
                if m: tds_section = m.group(1).upper()
                elif "194i" in nl: tds_section = "I"
                elif "194c" in nl: tds_section = "C"
                elif "194j" in nl: tds_section = "J"
            continue

        if nl in ["round off","roundoff","rounding off"]: continue
        if any(x in nl for x in BANK_KW): continue

        # Signal 3: Expense debit = taxable value
        if sa > 0: expense_debit += sa

        # Signal 4: Creditor = vendor/landlord
        if sa < 0 and not creditor_name:
            creditor_name = name

    return {
        "rcm_confirmed":  rcm_has_input or rcm_has_output,
        "rcm_has_input":  rcm_has_input,
        "rcm_has_output": rcm_has_output,
        "rcm_igst": rcm_igst, "rcm_cgst": rcm_cgst, "rcm_sgst": rcm_sgst,
        "fwd_igst": fwd_igst, "fwd_cgst": fwd_cgst, "fwd_sgst": fwd_sgst,
        "expense_debit": expense_debit,
        "creditor_name": creditor_name,
        "tds_section": tds_section,
    }


def match_rcm_audit(ledger_names, party, tds_section, gstin, sig):
    """
    Match against law-defined RCM categories.
    FORWARD CHARGE NEVER GOES INTO RCM.
    Same logic as Recon Bridge v9.
    """
    all_lower = (" ".join(ledger_names) + " " + (party or "")).lower()
    is_confirmed = sig["rcm_confirmed"]
    fwd_gst = sig["fwd_igst"] + sig["fwd_cgst"] + sig["fwd_sgst"]
    is_forward_charge = bool(gstin) and fwd_gst > 0

    # Sec 9(3) — mandatory RCM
    for rule in SEC_9_3_RULES:
        ledger_match = any(re.search(rule["ledger_regex"], ln.lower(), re.I) for ln in ledger_names)
        kw_match     = any(w in all_lower for w in rule["keywords"])
        tds_match    = tds_section and tds_section in rule.get("tds", [])
        if not (ledger_match or kw_match or tds_match): continue

        # Forward charge guard — director always RCM, confirmed entries trust books
        is_director = rule["cat"] == "RCM_DIRECTOR"
        if is_forward_charge and not is_director and not is_confirmed:
            return None, "forward_charge"

        return rule, ("confirmed" if is_confirmed else "missed")

    # Sec 9(4) — conditional rent RCM
    for rule in SEC_9_4_RULES:
        ledger_match = any(re.search(rule["ledger_regex"], ln.lower(), re.I) for ln in ledger_names)
        kw_match     = any(w in all_lower for w in rule["keywords"])
        tds_match    = tds_section and tds_section in rule.get("tds", [])
        if not (ledger_match or kw_match or tds_match): continue

        if is_confirmed:  return rule, "confirmed"
        if not gstin:     return rule, "possible"
        return None, "forward_charge"  # registered landlord = forward charge

    if is_confirmed:
        return {"cat":"RCM_OTHER","section":"RCM — Category not identified",
                "rate":"Verify","action":"RCM ledgers found but category unclear. Review manually.",
                "itc_eligible":None}, "confirmed"
    return None, None


def classify(vtype, party, ledgers, gstin, igst, cgst, sgst, amount,
             ledgers_with_amounts=None):
    """
    Law-first classify — same principle as Recon Bridge v9.
    ledgers_with_amounts: list of (name, signed_amount) for signal detection.
    Falls back to keyword-only if not provided (legacy path).
    """
    vt   = vtype.lower()
    txt  = (party + " " + " ".join(ledgers) + " " + vtype).lower()
    gst  = igst + cgst + sgst
    is_p = "purchase" in vt
    is_j = "journal" in vt
    is_py= "payment" in vt

    # ── Non-RCM checks first (unchanged) ──────────────────────────────────
    if kw(txt, COMP_KW):
        return dict(category="COMPOSITION_PURCHASE", itc_eligible=False, rcm=False,
                    reason="Composition dealer -- ITC NOT available per Sec 10(4).",
                    action="Do not claim ITC. Verify supplier on GST portal.")
    if kw(txt, ISD_KW):
        return dict(category="ISD_CREDIT", itc_eligible=True, rcm=False,
                    reason="ISD credit from head office.",
                    action="Ensure ISD invoice format correct per Rule 54(1).")
    if kw(txt, SEZ_KW):
        return dict(category="SEZ_PURCHASE", itc_eligible=True, rcm=False,
                    reason="Supply from SEZ -- zero-rated.",
                    action="Verify LUT/Bond. IGST (if any) claimable as ITC.")
    if kw(txt, IMPORT_KW) or (igst > 0 and not cgst and not sgst and not gstin and is_p):
        return dict(category="IMPORT_GOODS", itc_eligible=True, rcm=False,
                    reason="Import of goods. IGST at customs claimable as ITC.",
                    action="Obtain Bill of Entry. ITC on IGST only.")
    if kw(txt, JOB_KW):
        return dict(category="JOB_WORK", itc_eligible=True, rcm=False,
                    reason="Job work charges. ITC retained by principal per Sec 143.",
                    action="Ensure goods sent under challan (Form ITC-04).")
    if is_p and kw(txt, CAPITAL_KW):
        if kw(txt, BLOCKED_MOTOR) and not kw(txt, ["truck","commercial vehicle","goods vehicle","lcv","hcv"]):
            return dict(category="BLOCKED_MOTOR_VEHICLE", itc_eligible=False, rcm=False,
                        reason="Sec 17(5)(a) Motor vehicle -- ITC blocked.",
                        action="Cannot claim ITC.")
        return dict(category="CAPITAL_GOODS", itc_eligible=True, rcm=False,
                    reason="Capital goods. ITC fully claimable per Sec 16.",
                    action="Claim in GSTR-3B Table 4(A)(1). Track in fixed asset register.")
    for (kws, reason, cat, action) in BLOCKED_RULES:
        if kw(txt, kws):
            if cat == "BLOCKED_INSURANCE" and kw(txt, BLOCKED_INS_OK):
                return dict(category="GST_PURCHASE", itc_eligible=True, rcm=False,
                            reason="Group mediclaim/ESIC -- ITC eligible.",
                            action="Claim ITC.")
            return dict(category=cat, itc_eligible=False, rcm=False, reason=reason, action=action)

    # ── RCM — LAW FIRST ───────────────────────────────────────────────────
    # If signed ledger amounts provided, use signal-based detection
    if ledgers_with_amounts:
        sig = read_ledger_signals_audit(ledgers_with_amounts)
        fwd_gst = sig["fwd_igst"] + sig["fwd_cgst"] + sig["fwd_sgst"]

        # FORWARD CHARGE GUARD: registered supplier charging GST = not RCM
        # Exception: if RCM ledgers explicitly present = trust the accountant
        if gstin and fwd_gst > 0 and not sig["rcm_confirmed"]:
            # Forward charge — skip RCM detection entirely
            pass
        else:
            rule, flag = match_rcm_audit(ledgers, party, sig["tds_section"], gstin, sig)
            if rule and flag in ("confirmed","missed","possible"):
                itc_ok = rule.get("itc_eligible", True)
                if flag == "confirmed":
                    reason = f"{rule['section']} — RCM booked correctly."
                    action = rule["action"]
                    rcm_compliant = True
                elif flag == "missed":
                    reason = f"⚠ MISSED RCM — {rule['section']}. RCM liability NOT booked. DRC-03 + interest u/s 50 may apply."
                    action = f"URGENT: Pay RCM @ {rule['rate']} in GSTR-3B Table 3.1(d). File DRC-03. {rule['action']}"
                    rcm_compliant = False
                else:  # possible
                    reason = f"⚠ POSSIBLE MISSED RCM — {rule['section']}. Landlord GSTIN not found. If unregistered, RCM applies."
                    action = f"Verify landlord GST registration. If unregistered: pay RCM @ {rule['rate']}. {rule['action']}"
                    rcm_compliant = False
                return dict(category=rule["cat"], itc_eligible=itc_ok, rcm=True,
                            rcm_compliant=rcm_compliant, rcm_flag=flag,
                            reason=reason, action=action)

    else:
        # Legacy path: keyword-only (no signed amounts)
        # FORWARD CHARGE GUARD: GSTIN + GST charged = not RCM
        is_forward = is_p and gst > 0 and len(gstin) == 15
        if not is_forward:
            # Check Sec 9(3)
            for rule in SEC_9_3_RULES:
                all_lower = txt
                ledger_match = any(re.search(rule["ledger_regex"], ln.lower(), re.I) for ln in ledgers)
                kw_match     = any(w in all_lower for w in rule["keywords"])
                if ledger_match or kw_match:
                    is_director = rule["cat"] == "RCM_DIRECTOR"
                    if not (is_forward and not is_director):
                        itc_ok = rule.get("itc_eligible", True)
                        return dict(category=rule["cat"], itc_eligible=itc_ok, rcm=True,
                                    rcm_compliant=True, rcm_flag="confirmed",
                                    reason=rule["section"], action=rule["action"])
            # Check Sec 9(4) rent — only if no GSTIN
            for rule in SEC_9_4_RULES:
                ledger_match = any(re.search(rule["ledger_regex"], ln.lower(), re.I) for ln in ledgers)
                kw_match     = any(w in txt for w in rule["keywords"])
                if (ledger_match or kw_match) and not gstin:
                    return dict(category=rule["cat"], itc_eligible=True, rcm=True,
                                rcm_compliant=True, rcm_flag="confirmed",
                                reason=rule["section"], action=rule["action"])
            # Safety net: bare \brent with no GSTIN
            if re.search(r'\brent', txt) and not gstin and 'parent' not in txt:
                return dict(category="RCM_RENT_COMMERCIAL", itc_eligible=True, rcm=True,
                            rcm_compliant=True, rcm_flag="possible",
                            reason="Sec 9(4) Commercial rent — possible RCM from unregistered landlord @ 18%",
                            action="Verify landlord GST registration status and RCM applicability manually.")

    # ── Salary / Utilities ─────────────────────────────────────────────────
    SALARY_KW = ["salary","wages","wage","esi ","esic","epf","provident fund",
                 "pf contribution","gratuity","bonus","overtime","payroll","stipend"]
    if kw(txt, SALARY_KW):
        return dict(category="PAYROLL_NON_GST", itc_eligible=False, rcm=False,
                    reason="Salary/wages/payroll — not a GST supply.",
                    action="Ensure TDS deducted u/s 192. No GST treatment required.")
    UTILITY_KW = ["electricity","power bill","rajdhani power","bses","tata power",
                  "electricity charges","power charges","water charges","municipal tax"]
    if kw(txt, UTILITY_KW):
        return dict(category="EXEMPT_UTILITY", itc_eligible=False, rcm=False,
                    reason="Electricity/utility — exempt from GST. No ITC.",
                    action="No GST treatment required.")

    if kw(txt, ELG_PROF) and gst > 0:
        return dict(category="GST_PURCHASE", itc_eligible=True, rcm=False,
                    reason="Professional fees -- ITC eligible.",
                    action="Claim ITC. Reconcile with GSTR-2B.")
    if kw(txt, ELG_TELE) and gst > 0:
        return dict(category="GST_PURCHASE", itc_eligible=True, rcm=False,
                    reason="Telecom/internet -- ITC eligible.",
                    action="Claim ITC. Reconcile with GSTR-2B.")
    if kw(txt, ELG_BANK) and gst > 0:
        return dict(category="GST_PURCHASE", itc_eligible=True, rcm=False,
                    reason="Banking charges -- ITC eligible.",
                    action="Claim ITC. Reconcile with GSTR-2B.")
    if is_p and gst > 0 and len(gstin) == 15:
        return dict(category="GST_PURCHASE", itc_eligible=True, rcm=False,
                    reason="Regular GST purchase from registered supplier.",
                    action="Reconcile with GSTR-2B. Claim ITC only after appearing in 2B.")
    if len(gstin) == 15 and (gst > 0 or is_p):
        return dict(category="GST_PURCHASE", itc_eligible=True, rcm=False,
                    reason="GST purchase from registered supplier.",
                    action="Reconcile with GSTR-2B.")
    BANK_CASH_KW = ["hdfc bank","icici bank","sbi bank","axis bank","kotak bank",
                    "current account","savings account","cash account","petty cash"]
    if kw(txt, BANK_CASH_KW) and gst == 0 and not gstin:
        return dict(category="BANK_PAYMENT_ENTRY", itc_eligible=False, rcm=False,
                    reason="Bank/cash settlement — not an expense.",
                    action="No GST action required.")
    if is_p and gst == 0 and not gstin and amount > 0:
        return dict(category="UNREGISTERED_PURCHASE", itc_eligible=False, rcm=True,
                    reason="Purchase from supplier without GSTIN. Verify RCM under Sec 9(4).",
                    action="Check supplier registration. If unregistered, verify RCM applicability.")
    if kw(txt, REL_KW):
        return dict(category="RELATED_PARTY", itc_eligible=True, rcm=False,
                    reason="Related party transaction. Must be at open market value.",
                    action="Ensure arm's length pricing. Document basis.")
    if (is_j or is_py) and gst > 0 and len(gstin) == 15:
        return dict(category="GST_EXPENSE", itc_eligible=True, rcm=False,
                    reason="Expense with GST from registered supplier. ITC likely eligible.",
                    action="Verify not blocked under Sec 17(5). Reconcile with GSTR-2B.")
    if gst == 0 and amount > 0:
        m = re.search(r'194\s?-?\s?([a-zA-Z])\b', txt)
        if m:
            sec = m.group(1).upper()
            sec_map = {'J':'Professional/Technical','C':'Contractor/GTA','I':'Rent','H':'Commission'}
            if sec in sec_map:
                return dict(category="RCM_TDS_FLAGGED", itc_eligible=None, rcm=True,
                    reason=f"TDS u/s 194{sec} ({sec_map[sec]}) + zero GST = strong RCM indicator.",
                    action=f"Verify if RCM applies. Pull invoice and confirm GST treatment.")
    if (is_j or is_py) and gst == 0 and amount > 0:
        return dict(category="EXPENSE_NO_GST", itc_eligible=False, rcm=False,
                    reason="Expense with no GST. Supplier may be exempt or unregistered.",
                    action="Verify supplier status.")
    if len(gstin) == 15 and amount > 0:
        return dict(category="GST_EXPENSE", itc_eligible=True, rcm=False,
                    reason="Expense with valid supplier GSTIN. ITC likely eligible.",
                    action="Verify not blocked under Sec 17(5). Check GSTR-2B.")
    return dict(category="REVIEW_NEEDED", itc_eligible=None, rcm=False,
                reason="Could not auto-classify. Manual review required.",
                action="Review ledger names and apply correct GST treatment.")


# ==============================================================================
# MAIN APP CLASS (unchanged from v2 except _fetch_purchases)
# ==============================================================================

class App:
    def __init__(self, root):
        self.root    = root
        self.root.title("GSTAgent Audit Bridge v3.0 — Law-First RCM")
        self.root.geometry("660x740")
        self.root.resizable(False, False)
        self.root.configure(bg=BG)
        self._running = False
        cfg = load_config()
        self.var_code    = tk.StringVar(value=cfg.get("sub_code",  ""))
        self.var_gstin   = tk.StringVar(value=cfg.get("gstin",     ""))
        self.var_company = tk.StringVar(value=cfg.get("company",   ""))
        self.var_from    = tk.StringVar(value=cfg.get("from_date", "01-04-2025"))
        self.var_to      = tk.StringVar(value=cfg.get("to_date",   "31-03-2026"))
        self.var_port    = tk.StringVar(value=cfg.get("port",      "9000"))
        self._build_ui()
        self.root.after(800, self._ping)

    def _build_ui(self):
        hdr = tk.Frame(self.root, bg=TEAL, height=56)
        hdr.pack(fill="x"); hdr.pack_propagate(False)
        tk.Label(hdr, text="GSTAgent", bg=TEAL, fg=WHITE, font=("Segoe UI",16,"bold")).place(x=18,y=15)
        tk.Label(hdr, text="Audit Bridge v3.0 — Law-First RCM", bg=TEAL, fg="#CBD5E1", font=("Segoe UI",11)).place(x=148,y=18)
        self.dot = tk.Label(hdr, text="● Checking Tally...", font=FONT, fg=AMBER, bg=TEAL)
        self.dot.place(relx=1.0, x=-16, y=18, anchor="ne")
        self.status_bar = tk.Frame(self.root, bg="#1E293B", height=32)
        self.status_bar.pack(fill="x"); self.status_bar.pack_propagate(False)
        self.status_lbl = tk.Label(self.status_bar, text="  Initialising...", bg="#1E293B", fg=INK3, font=FONT_SM)
        self.status_lbl.pack(side="left", padx=8, pady=6)
        self.btn_ping = tk.Button(self.status_bar, text="⟳ Test Connection", command=self._ping,
                                  bg="#1E293B", fg=INK3, relief="flat", font=FONT_SM, cursor="hand2",
                                  activebackground="#334155", activeforeground=WHITE)
        self.btn_ping.pack(side="right", padx=8, pady=4)
        body = tk.Frame(self.root, bg=BG, padx=24, pady=16)
        body.pack(fill="both", expand=True)
        self._section(body, "Account")
        f1 = tk.Frame(body, bg=BG); f1.pack(fill="x", pady=(0,10))
        self._field(f1, "GSTAgent Subscription Code", self.var_code, "BSA-XXXXXXXX or CAA-XXXXXXXX", 0)
        f1b = tk.Frame(body, bg=BG); f1b.pack(fill="x", pady=(0,10))
        self._field(f1b, "Client GSTIN (required — same one saved in GSTAgent)", self.var_gstin, "e.g. 07AAQFV2896P1ZY", 0)
        self._section(body, "Tally Settings")
        f2 = tk.Frame(body, bg=BG); f2.pack(fill="x", pady=(0,10))
        self._field(f2, "Company Name in TallyPrime (leave blank = active company)", self.var_company, "Auto-detected from Tally", 0)
        f3 = tk.Frame(body, bg=BG); f3.pack(fill="x", pady=(0,10))
        for col,(label,var,ph) in enumerate([
            ("From Date (DD-MM-YYYY)",self.var_from,"01-04-2025"),
            ("To Date (DD-MM-YYYY)",  self.var_to,  "31-03-2026"),
            ("Tally Port",            self.var_port, "9000"),
        ]):
            tk.Label(f3,text=label,bg=BG,fg=INK2,font=FONT_SM).grid(row=0,column=col,sticky="w",padx=(0,12),pady=(0,2))
            e = tk.Entry(f3,textvariable=var,font=FONT,width=14,relief="flat",bg=WHITE,fg=INK,
                         highlightthickness=1,highlightbackground=BORDER,highlightcolor=TEAL)
            e.grid(row=1,column=col,sticky="w",padx=(0,12),ipady=4)
        self._section(body, "Data Synced to Audit Engine")
        info = tk.Frame(body,bg="#EDF7F6",relief="flat",highlightthickness=1,highlightbackground="#B2DDD9")
        info.pack(fill="x",pady=(0,12))
        for icon,label,desc in [
            ("✓","Purchase register","37 GST categories — ITC working (4A/4B/4C/4D)"),
            ("✓","Sales / Output","Outward liability by type — taxable, export, exempt"),
            ("✓","GST ledger balances","IGST/CGST/SGST debit & credit movements from Tally"),
            ("✓","Net GST position","Output liability minus ITC — actual payable"),
        ]:
            row_f = tk.Frame(info,bg="#EDF7F6"); row_f.pack(fill="x",padx=10,pady=3)
            tk.Label(row_f,text=icon,bg="#EDF7F6",fg=TEAL,font=("Segoe UI",10,"bold"),width=2).pack(side="left")
            tk.Label(row_f,text=label,bg="#EDF7F6",fg=INK,font=("Segoe UI",9,"bold"),width=20,anchor="w").pack(side="left")
            tk.Label(row_f,text=desc,bg="#EDF7F6",fg=INK2,font=FONT_SM).pack(side="left")
        self._section(body,"")
        tk.Button(body,text="✕ Clear Saved Details",command=self._clear_config,bg=BG,fg=INK3,
                  relief="flat",font=FONT_SM,cursor="hand2",activebackground=BG,activeforeground=RED).pack(anchor="e",pady=(0,4))
        self.btn_sync = tk.Button(body,text="▶  Start Full Audit Sync",command=self._start_sync,
                                  bg=TEAL,fg=WHITE,font=("Segoe UI",11,"bold"),relief="flat",
                                  cursor="hand2",padx=20,pady=10,activebackground=TEAL2,activeforeground=WHITE)
        self.btn_sync.pack(fill="x",pady=(0,10))
        self.progress_frame = tk.Frame(body,bg=BG); self.progress_frame.pack(fill="x",pady=(0,6))
        self.progress_steps = []
        for step in ["Connect","Purchases","Sales","Ledgers","Upload","Done"]:
            lbl = tk.Label(self.progress_frame,text=step,bg=BG,fg=INK3,font=("Segoe UI",8),width=8)
            lbl.pack(side="left",expand=True); self.progress_steps.append(lbl)
        self._section(body,"Live Log")
        self.log = scrolledtext.ScrolledText(body,height=7,font=MONO,bg="#0F172A",fg="#94A3B8",
                                             relief="flat",state="disabled",wrap="word")
        self.log.pack(fill="x")

    def _section(self,parent,title):
        f = tk.Frame(parent,bg=BG); f.pack(fill="x",pady=(4,4))
        if title:
            tk.Label(f,text=title.upper(),bg=BG,fg=TEAL,font=("Segoe UI",8,"bold")).pack(side="left")
            tk.Frame(f,bg=BORDER,height=1).pack(side="left",fill="x",expand=True,padx=(8,0),pady=6)

    def _field(self,parent,label,var,ph,row):
        tk.Label(parent,text=label,bg=BG,fg=INK2,font=FONT_SM).grid(row=row*2,column=0,sticky="w",pady=(0,2))
        e = tk.Entry(parent,textvariable=var,font=FONT,width=56,relief="flat",bg=WHITE,fg=INK,
                     highlightthickness=1,highlightbackground=BORDER,highlightcolor=TEAL)
        e.grid(row=row*2+1,column=0,sticky="ew",pady=(0,8),ipady=5)
        if ph and not var.get():
            e.insert(0,ph); e.config(fg=INK3)
            def on_in(ev,en=e,p=ph):
                if en.get()==p: en.delete(0,"end"); en.config(fg=INK)
            def on_out(ev,en=e,p=ph):
                if not en.get(): en.insert(0,p); en.config(fg=INK3)
            e.bind("<FocusIn>",on_in); e.bind("<FocusOut>",on_out)

    def _log(self,msg,color="#94A3B8"):
        ts = datetime.now().strftime("%H:%M:%S")
        def do():
            self.log.config(state="normal")
            self.log.insert("end",f"[{ts}]  {msg}\n",color)
            self.log.tag_config(color,foreground=color)
            self.log.see("end"); self.log.config(state="disabled")
        self.root.after(0,do)

    def _set_step(self,idx,active=False,done=False):
        def do():
            for i,lbl in enumerate(self.progress_steps):
                if i<idx: lbl.config(fg=TEAL,font=("Segoe UI",8,"bold"))
                elif i==idx: lbl.config(fg=AMBER if active else TEAL,font=("Segoe UI",8,"bold"))
                else: lbl.config(fg=INK3,font=("Segoe UI",8))
        self.root.after(0,do)

    def _set_status(self,msg,color=INK3):
        def do(): self.status_lbl.config(text=f"  {msg}",fg=color)
        self.root.after(0,do)

    def _ping(self):
        def run():
            port = self.var_port.get().strip() or "9000"
            self.root.after(0,lambda: self.dot.config(text="● Checking...",fg=AMBER))
            self._set_status("Testing Tally connection...",AMBER)
            company = check_alive(port)
            if company:
                self.root.after(0,lambda: self.dot.config(text=f"● Tally Connected — {company[:30]}",fg="#86EFAC"))
                self._set_status(f"TallyPrime connected · {company[:40]}",GREEN)
                self._log(f"✓ TallyPrime connected — {company}",GREEN)
                cfg_company = self.var_company.get().strip()
                if not cfg_company or cfg_company in ["Auto-detected from Tally",""]:
                    self.root.after(0,lambda: self.var_company.set(company))
            else:
                self.root.after(0,lambda: self.dot.config(text="● Tally Not Found",fg="#FCA5A5"))
                self._set_status("TallyPrime not responding — open Tally with a company loaded",RED)
                self._log("Tally not responding on port "+port+" — make sure TallyPrime is open",RED)
        threading.Thread(target=run,daemon=True).start()

    def _validate(self):
        code = self.var_code.get().strip()
        if not code or code in ["BSA-XXXXXXXX or CAA-XXXXXXXX",""]:
            messagebox.showerror("Missing","Enter your GSTAgent Subscription Code"); return None
        gstin = self.var_gstin.get().strip().upper()
        if not gstin or len(gstin)!=15:
            messagebox.showerror("Missing GSTIN","Enter the client's 15-character GSTIN."); return None
        return {"code":code,"gstin":gstin,"from":self.var_from.get().strip(),
                "to":self.var_to.get().strip(),"port":self.var_port.get().strip() or "9000",
                "company":self.var_company.get().strip()}

    def _start_sync(self):
        if self._running: return
        params = self._validate()
        if not params: return
        save_config({"sub_code":params["code"],"gstin":params["gstin"],"company":params["company"],
                     "from_date":params["from"],"to_date":params["to"],"port":params["port"]})
        self._running = True
        self.btn_sync.config(state="disabled",text="⏳  Syncing...",bg="#6B7280")
        threading.Thread(target=self._sync_thread,args=(params,),daemon=True).start()

    def _sync_thread(self,p):
        try:
            port=p["port"]; code=p["code"]; gstin=p["gstin"]; company=p["company"]
            def to_tally_date(s):
                s=s.strip()
                for fmt in ["%d-%m-%Y","%d/%m/%Y","%Y%m%d"]:
                    try:
                        from datetime import datetime as _dt
                        return _dt.strptime(s,fmt).strftime("%Y%m%d")
                    except: pass
                return s
            from_d=to_tally_date(p["from"]); to_d=to_tally_date(p["to"])
            self._log(f"Date range: {from_d} → {to_d}","#94A3B8")
            self._log(f"Starting Audit Sync  |  {from_d} → {to_d}","#22D3EE")
            self._set_step(0,active=True); self._set_status("Connecting to TallyPrime...",AMBER)
            co = check_alive(port)
            if not co:
                self._log("Cannot reach Tally — sync cancelled",RED)
                self._set_status("Tally not connected — open TallyPrime first",RED)
                self._done(); return
            if (not company or company in ["Auto-detected from Tally",""]) and co and co!="Connected":
                company=co
            elif not company or company in ["Auto-detected from Tally",""]:
                company=""
            self._log(f"✓ TallyPrime connected — {co}",GREEN); self._set_step(0,done=True)
            self._set_step(1,active=True); self._set_status("Fetching purchase register...",AMBER)
            self._log("Fetching purchases...","#94A3B8")
            classified,summary,all_vchs = self._fetch_purchases(company,from_d,to_d,port)
            total_pr = sum(len(v) for v in classified.values())
            missed_rcm = sum(len([r for r in v if r.get("rcm_flag")=="missed"]) for v in classified.values())
            self._log(f"✓ Purchases: {total_pr} entries | {missed_rcm} missed RCM flagged",GREEN)
            self._set_step(1,done=True)
            self._set_step(2,active=True); self._set_status("Fetching sales...",AMBER)
            output_data = self._fetch_output(company,from_d,to_d,port)
            total_sales = sum(len(v) for v in output_data.values())
            self._log(f"✓ Sales: {total_sales} entries",GREEN); self._set_step(2,done=True)
            self._set_step(3,active=True); self._set_status("Fetching GST ledger balances...",AMBER)
            ledger_balances = self._fetch_ledger_balances(company,from_d,to_d,port)
            self._log(f"✓ GST ledgers: {len(ledger_balances)} accounts found",GREEN)
            net_payable = sum(v.get("net",0) for v in ledger_balances.values() if "PAYABLE" in v.get("type",""))
            net_itc     = sum(v.get("net",0) for v in ledger_balances.values() if "INPUT" in v.get("type",""))
            self._log(f"  Output liability: ₹{abs(net_payable):,.0f}  |  ITC available: ₹{abs(net_itc):,.0f}","#FDE68A")
            self._log("Tracing Balance Sheet...","#94A3B8")
            balance_sheet_gst = self._fetch_balance_sheet_gst(company,from_d,to_d,port,classified)
            self._log("Checking for bill-wise payment data...","#94A3B8")
            bill_wise_payments = self._fetch_bill_wise_payments(all_vchs,classified)
            self._set_step(3,done=True)
            self._set_step(4,active=True); self._set_status("Uploading to GSTAgent...",AMBER)
            self._log("Uploading to GSTAgent Audit Engine...","#A3E635")
            payload = {
                "subscriptionCode":code,"gstin":gstin,
                "classified_data":classified,"summary":summary,
                "output_data":output_data,"ledger_balances":ledger_balances,
                "balance_sheet_gst":balance_sheet_gst,"bill_wise_payments":bill_wise_payments,
                "synced_at":datetime.now().isoformat(),
            }
            r = requests.post(f"{GSTAGENT_API}/api/audit-sync",json=payload,timeout=120,
                              headers={"Content-Type":"application/json"})
            if r.status_code==200:
                d=r.json()
                self._log(f"✓ Uploaded — {d.get('entries',0)} entries synced",GREEN)
                self._set_step(4,done=True); self._set_step(5,done=True)
                self._set_status(f"Sync complete — {total_pr} purchases · {total_sales} sales",GREEN)
                self.root.after(0,lambda: messagebox.showinfo("Sync Complete",
                    f"Audit data synced to GSTAgent!\n\n"
                    f"Purchases:        {total_pr} entries\n"
                    f"Missed RCM (⚠):  {missed_rcm} entries\n"
                    f"Sales:            {total_sales} entries\n"
                    f"GST ledgers:      {len(ledger_balances)} accounts\n\n"
                    f"Open gstagent.in/audit to view your Audit Engine."))
            else:
                self._log(f"Upload failed ({r.status_code}): {r.text[:200]}",RED)
                self._set_status("Upload failed — check log",RED)
        except Exception as e:
            self._log(f"Error: {e}",RED); self._set_status("Error — see log",RED)
        finally:
            self._done()

    def _clear_config(self):
        if CONFIG_FILE.exists():
            try: CONFIG_FILE.unlink()
            except: pass
        for var in [self.var_code,self.var_company,self.var_from,self.var_to]: var.set("")
        self._log("Saved details cleared","#94A3B8")

    def _done(self):
        self._running=False
        self.root.after(0,lambda: self.btn_sync.config(state="normal",text="▶  Start Full Audit Sync",bg=TEAL))
        self.root.after(0,lambda: self._set_step(-1))

    def _sanitize_xml(self,raw):
        raw = re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;)','&amp;',raw)
        raw = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]','',raw)
        return raw

    def _fetch_ledger_master(self,company,port):
        cmp = f"<SVCURRENTCOMPANY>{company}</SVCURRENTCOMPANY>" if company else ""
        parent_map: dict = {}
        try:
            xml = ("<ENVELOPE><HEADER><TALLYREQUEST>Export Data</TALLYREQUEST></HEADER>"
                   "<BODY><EXPORTDATA><REQUESTDESC><STATICVARIABLES>"+cmp+
                   "<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>"
                   "</STATICVARIABLES></REQUESTDESC>"
                   "<REQUESTDATA><TALLYMESSAGE>"
                   '<COLLECTION ISMODIFIABLE="No" ISFULLEXPORT="Yes">'
                   "<TYPE>Ledger</TYPE>"
                   "<FETCH>NAME,GSTREGISTRATIONNUMBER,PARENTNAME,PARENT</FETCH>"
                   "</COLLECTION></TALLYMESSAGE></REQUESTDATA></EXPORTDATA></BODY></ENVELOPE>")
            r = requests.post(f"http://localhost:{port}",data=xml.encode("utf-8"),
                              headers={"Content-Type":"text/xml"},timeout=60)
            raw = re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;)','&amp;',r.text)
            raw = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]','',raw)
            if len(raw)>500:
                root = ET.fromstring(raw) if raw.strip().startswith("<") else None
                if root:
                    for led in root.iter("LEDGER"):
                        name=(led.get("NAME") or led.findtext("NAME") or "").strip()
                        parent=(led.findtext("PARENTNAME") or led.findtext("PARENT") or "").strip().lower()
                        if name and parent: parent_map[name.upper()]=parent
            self._log(f"  Ledger master: {len(parent_map)} ledgers with parent group","#94A3B8")
        except Exception as e:
            self._log(f"  Ledger master fetch: {e}","#94A3B8")
        return parent_map

    def _fetch_all_vouchers(self,company,from_d,to_d,port):
        cmp = f"<SVCURRENTCOMPANY>{company}</SVCURRENTCOMPANY>" if company else ""
        def clean(raw):
            raw=re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;)','&amp;',raw)
            return re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]','',raw)
        try:
            xml = ("<ENVELOPE><HEADER><TALLYREQUEST>Export Data</TALLYREQUEST></HEADER>"
                   "<BODY><EXPORTDATA><REQUESTDESC><REPORTNAME>Voucher Register</REPORTNAME>"
                   "<STATICVARIABLES>"+cmp+
                   "<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>"
                   f"<SVFROMDATE>{from_d}</SVFROMDATE><SVTODATE>{to_d}</SVTODATE>"
                   f"<SVDISPLAYFROMDATE>{from_d}</SVDISPLAYFROMDATE>"
                   f"<SVDISPLAYTODATE>{to_d}</SVDISPLAYTODATE>"
                   f"<SVCURRENTPERIODFROMDATE>{from_d}</SVCURRENTPERIODFROMDATE>"
                   f"<SVCURRENTPERIODTODATE>{to_d}</SVCURRENTPERIODTODATE>"
                   "</STATICVARIABLES></REQUESTDESC></EXPORTDATA></BODY></ENVELOPE>")
            r=requests.post(f"http://localhost:{port}",data=xml.encode("utf-8"),
                            headers={"Content-Type":"text/xml"},timeout=300)
            raw=r.text
            self._log(f"  Voucher Register: {len(raw):,} bytes","#94A3B8")
            if len(raw)>2000:
                root=ET.fromstring(clean(raw))
                vchs=[v for v in root.iter("VOUCHER")
                      if from_d<=(v.findtext("DATE") or "99991231")<=to_d]
                if vchs:
                    types={}
                    for v in vchs:
                        vt=(v.findtext("VOUCHERTYPENAME") or v.get("VCHTYPE","?")).strip()
                        types[vt]=types.get(vt,0)+1
                    self._log(f"  {len(vchs)} vouchers: "+", ".join(f"{k}:{v}" for k,v in sorted(types.items(),key=lambda x:-x[1])[:6]),"#94A3B8")
                    return vchs
        except Exception as e:
            self._log(f"  Voucher Register error: {e}",AMBER)
        self._log("  No vouchers — check company name and date range",AMBER)
        return []

    def _fetch_purchases(self, company, from_d, to_d, port):
        try:
            parent_map = self._fetch_ledger_master(company, port)
            all_vchs   = self._fetch_all_vouchers(company, from_d, to_d, port)
            classified = {}
            INWARD     = {"purchase","debit note","debit-note","journal","payment"}
            for v in all_vchs:
                vt = (v.findtext("VOUCHERTYPENAME") or v.get("VCHTYPE","")).strip().lower()
                if not any(x in vt for x in INWARD): continue
                inv   = (v.findtext("SUPPLIERINVOICENUMBER") or v.findtext("REFERENCE") or
                         v.findtext(".//BILLALLOCATIONS.LIST/NAME") or
                         v.findtext("VOUCHERNUMBER") or "").strip()
                dt    = fmt_date(v.findtext("DATE") or "")
                party = (v.findtext("PARTYNAME") or v.findtext("PARTYLEDGERNAME") or "").strip()
                gstin = ""
                for tag in ["PARTYGSTIN","BUYERGSTIN","GSTREGISTRATIONNUMBER"]:
                    g = (v.findtext(tag) or v.findtext(".//" + tag) or "").strip().upper()
                    if len(g) == 15: gstin = g; break
                ledgers = []; ig = cg = sg = gross = 0.0
                ledgers_signed = []  # v3: signed amounts for law-first signal detection
                for list_tag in ["LEDGERENTRIES.LIST", "ALLLEDGERENTRIES.LIST"]:
                    for le in v.iter(list_tag):
                        ln  = (le.findtext("LEDGERNAME") or "").strip()
                        af  = pa(le.findtext("AMOUNT") or "0")
                        sa  = spa(le.findtext("AMOUNT") or "0")
                        lnl = ln.lower()
                        if ln and ln not in ledgers: ledgers.append(ln)
                        if ln: ledgers_signed.append((ln, sa))
                        if not gstin:
                            g = (le.findtext("GSTREGISTRATIONNUMBER") or "").strip().upper()
                            if len(g) == 15: gstin = g
                        is_output = any(x in lnl for x in ["output","payable","liability"])
                        if not is_output and any(x in lnl for x in ["igst","integrated gst","igst input","input igst"]):
                            ig += abs(af)
                        elif not is_output and any(x in lnl for x in ["cgst","central gst","cgst input","input cgst"]):
                            cg += abs(af)
                        elif not is_output and any(x in lnl for x in ["sgst","state gst","utgst","sgst input","input sgst"]):
                            sg += abs(af)
                        elif not any(x in lnl for x in ["gst","tax","duty","cess","round off","roundoff"]):
                            gross += af
                total = ig + cg + sg
                tv    = max(0.0, gross - total)
                if not party and ledgers: party = ledgers[0]
                if total == 0 and tv == 0 and gross == 0 and not party: continue
                is_rcm_journal = any("rcm" in l.lower() for l in ledgers)
                all_led = " ".join(l.lower() for l in ledgers)
                if not is_rcm_journal and any(x in all_led for x in ["output igst","output cgst","output sgst","igst payable","cgst payable","sgst payable"]): continue

                # v3: law-first classify with signed ledger amounts
                result_cl = classify(vt, party, ledgers, gstin, ig, cg, sg,
                                     max(tv, gross),
                                     ledgers_with_amounts=ledgers_signed)
                cat = result_cl.get('category', 'REVIEW_NEEDED')

                # Capital goods override (unchanged)
                DEBIT_LEDGER_NAMES = [l for l in ledgers
                    if not re.search(r"igst|cgst|sgst|utgst|input tax|output tax|cash|bank|sundry|creditor|payable", l, re.I)]
                if cat in ('GST_PURCHASE', 'REVIEW_NEEDED', 'GST_EXPENSE') and vt in ('purchase',):
                    for ln in DEBIT_LEDGER_NAMES:
                        pg = parent_map.get(ln.upper(), "").lower()
                        if pg in ('fixed assets','plant & machinery','plant and machinery','capital work in progress','capital wip'):
                            cat = 'CAPITAL_GOODS'; break
                    if cat != 'CAPITAL_GOODS' and not parent_map:
                        for ln in DEBIT_LEDGER_NAMES:
                            if re.search(r"plant|machiner|equipm|fixed asset|capital asset|tool|lathe|cnc|compressor|boiler|generator|inverter|solar panel", ln, re.I):
                                if not re.search(r"repair|service|spare|consumable|polish|hire|rent|charge|fee|oil|accessory", ln, re.I):
                                    cat = 'CAPITAL_GOODS'; break

                # v3: rcm_compliant and rcm_flag from law-first classify result
                rcm_compliant = result_cl.get("rcm_compliant", True)
                rcm_flag      = result_cl.get("rcm_flag", "")
                has_rcm_output = any(
                    "rcm output" in l.lower() or "rcm liability" in l.lower() or
                    ("output" in l.lower() and ("cgst" in l.lower() or "sgst" in l.lower() or "igst" in l.lower()) and "rcm" in l.lower())
                    for l in ledgers
                )
                has_rcm_input = any(
                    "rcm input" in l.lower() or
                    ("input" in l.lower() and ("cgst" in l.lower() or "sgst" in l.lower() or "igst" in l.lower()) and "rcm" in l.lower())
                    for l in ledgers
                )
                itc_reclaimed = (
                    not result_cl.get("rcm", False) or
                    cat in NO_ITC_CATS or
                    not has_rcm_output or
                    has_rcm_input
                )

                # v3: taxVal fix for RCM journals (expense debit, not gross netting)
                sig = read_ledger_signals_audit(ledgers_signed)
                final_tv = tv
                if cat.startswith("RCM_") and final_tv == 0 and sig["expense_debit"] > 0:
                    final_tv = round(sig["expense_debit"], 2)
                if not party and sig["creditor_name"]:
                    party = sig["creditor_name"]

                classified.setdefault(cat, []).append({
                    "invNo": inv, "date": dt, "name": party, "gstin": gstin,
                    "taxVal": final_tv if cat.startswith("RCM_") else round(tv, 2),
                    "igst": round(ig,2), "cgst": round(cg,2), "sgst": round(sg,2),
                    "ledgers": ledgers[:3],
                    "rcm_compliant": rcm_compliant,
                    "rcm_flag":      rcm_flag,
                    "itc_reclaimed": itc_reclaimed,
                    "rcm_applicable": result_cl.get("rcm", False),
                })
            summary = {cat: len(entries) for cat, entries in classified.items() if entries}
            return classified, summary, all_vchs
        except Exception as e:
            self._log(f"Purchase fetch error: {e}", RED)
            return {}, {}, []


    def _fetch_output(self,company,from_d,to_d,port):
        OUTPUT_CATS={"SALES_TAXABLE":r"(customer|debtor|buyer|sale|b2b|b2c)",
                     "SALES_EXPORT":r"(export|lut|bond|zero.*rate)",
                     "SALES_EXEMPT":r"(exempt|nil.*rate)",
                     "CREDIT_NOTE_OUT":r"(credit.*note|sale.*return)",
                     "DEBIT_NOTE_OUT":r"(debit.*note)"}
        try:
            all_vchs=self._fetch_all_vouchers(company,from_d,to_d,port)
            OUTWARD={"sales","sale","credit note","credit-note"}
            result={cat:[] for cat in OUTPUT_CATS}
            for v in all_vchs:
                vt=(v.findtext("VOUCHERTYPENAME") or v.get("VCHTYPE","")).strip().lower()
                if not any(x in vt for x in OUTWARD): continue
                inv=(v.findtext("VOUCHERNUMBER") or "").strip()
                dt=fmt_date(v.findtext("DATE") or "")
                party=(v.findtext("PARTYNAME") or "").strip()
                ig=cg=sg=gross_s=0.0
                for list_tag in ["LEDGERENTRIES.LIST","ALLLEDGERENTRIES.LIST"]:
                    for le in v.iter(list_tag):
                        ln=(le.findtext("LEDGERNAME") or "").strip()
                        af=pa(le.findtext("AMOUNT") or "0"); lnl=ln.lower()
                        if any(x in lnl for x in ["igst","integrated gst"]): ig+=af
                        elif any(x in lnl for x in ["cgst","central gst"]): cg+=af
                        elif any(x in lnl for x in ["sgst","state gst","utgst"]): sg+=af
                        elif not any(x in lnl for x in ["gst","tax","duty","cess","round"]): gross_s+=af
                tv=max(0.0,gross_s-(ig+cg+sg))
                if ig+cg+sg==0 and tv==0: continue
                if not party: party=inv
                cat="SALES_TAXABLE"
                for c_name,pattern in OUTPUT_CATS.items():
                    if re.search(pattern,party.lower(),re.I): cat=c_name; break
                result[cat].append({"invNo":inv,"date":dt,"name":party,"gstin":"",
                    "taxVal":round(tv,2),"igst":round(ig,2),"cgst":round(cg,2),"sgst":round(sg,2)})
            return result
        except Exception as e:
            self._log(f"Sales fetch error: {e}",RED); return {}

    def _fetch_ledger_balances(self,company,from_d,to_d,port):
        cmp=f"<SVCURRENTCOMPANY>{company}</SVCURRENTCOMPANY>" if company else ""
        xml=("<ENVELOPE><HEADER><TALLYREQUEST>Export Data</TALLYREQUEST></HEADER>"
             "<BODY><EXPORTDATA><REQUESTDESC>"
             "<REPORTNAME>Group Summary</REPORTNAME>"
             "<STATICVARIABLES>"+cmp+
             "<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>"
             f"<SVFROMDATE>{from_d}</SVFROMDATE><SVTODATE>{to_d}</SVTODATE>"
             f"<SVDISPLAYFROMDATE>{from_d}</SVDISPLAYFROMDATE>"
             f"<SVDISPLAYTODATE>{to_d}</SVDISPLAYTODATE>"
             "<GROUPNAME>Duties &amp; Taxes</GROUPNAME>"
             "</STATICVARIABLES></REQUESTDESC></EXPORTDATA></BODY></ENVELOPE>")
        balances={}
        try:
            raw=requests.post(f"http://localhost:{port}",data=xml.encode("utf-8"),
                              headers={"Content-Type":"text/xml"},timeout=60).text
            raw=re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;)','&amp;',raw)
            raw=re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]','',raw)
            if len(raw)>500:
                root=ET.fromstring(raw) if raw.strip().startswith("<") else None
                if root:
                    for ledger in root.iter("LEDGER"):
                        name=ledger.get("NAME","") or (ledger.findtext("NAME") or "")
                        if not name: continue
                        nl=name.lower()
                        if not any(re.search(p,nl) for p in [
                            r"igst|integrated.*gst",r"cgst|central.*gst",
                            r"sgst|state.*gst|utgst",r"input.*tax|itc|gst.*input"]): continue
                        def gv(tag,led=ledger):
                            el=led.find(f".//{tag}")
                            if el is not None and el.text:
                                try: return float(el.text.replace(",",""))
                                except: return 0.0
                            return 0.0
                        ob=gv("OPENINGBALANCE");cb=gv("CLOSINGBALANCE")
                        dr=gv("DEBITAMOUNT");cr=gv("CREDITAMOUNT")
                        lt=("IGST_PAYABLE" if re.search(r"igst|integrated",nl) and re.search(r"payable|output|duty",nl)
                            else "IGST_INPUT" if re.search(r"igst|integrated",nl)
                            else "CGST_PAYABLE" if re.search(r"cgst|central",nl) and re.search(r"payable|output|duty",nl)
                            else "CGST_INPUT" if re.search(r"cgst|central",nl)
                            else "SGST_PAYABLE" if re.search(r"sgst|state|utgst",nl) and re.search(r"payable|output|duty",nl)
                            else "SGST_INPUT" if re.search(r"sgst|state|utgst",nl) else "GST_OTHER")
                        balances[name]={"type":lt,"opening":ob,"closing":cb,"debit":dr,"credit":cr,"net":cr-dr}
        except Exception as e:
            self._log(f"Ledger balance error: {e}",AMBER)
        if not balances:
            self._log("  Using voucher-derived ledger totals as fallback","#94A3B8")
            try:
                vchs=self._fetch_all_vouchers(company,from_d,to_d,port)
                ledger_totals: dict={}
                for v in vchs:
                    for lt in ["LEDGERENTRIES.LIST","ALLLEDGERENTRIES.LIST"]:
                        for le in v.iter(lt):
                            ln=(le.findtext("LEDGERNAME") or "").strip()
                            af=pa(le.findtext("AMOUNT") or "0"); nl=ln.lower()
                            if any(re.search(p,nl) for p in [r"igst",r"cgst",r"sgst|utgst",r"input.*tax",r"gst.*input"]):
                                if ln not in ledger_totals: ledger_totals[ln]=0.0
                                ledger_totals[ln]+=af
                for ln,total in ledger_totals.items():
                    nl=ln.lower()
                    lt=("IGST_PAYABLE" if re.search(r"igst",nl) and re.search(r"payable|output",nl)
                        else "IGST_INPUT" if re.search(r"igst",nl)
                        else "CGST_PAYABLE" if re.search(r"cgst",nl) and re.search(r"payable|output",nl)
                        else "CGST_INPUT" if re.search(r"cgst",nl)
                        else "SGST_PAYABLE" if re.search(r"sgst|utgst",nl) and re.search(r"payable|output",nl)
                        else "SGST_INPUT" if re.search(r"sgst|utgst",nl) else "GST_OTHER")
                    balances[ln]={"type":lt,"opening":0,"closing":total,"debit":0,"credit":total,"net":total}
            except Exception as e:
                self._log(f"Voucher ledger fallback error: {e}",AMBER)
        return balances

    def _fetch_balance_sheet_gst(self,company,from_d,to_d,port,classified):
        cmp=f"<SVCURRENTCOMPANY>{company}</SVCURRENTCOMPANY>" if company else ""
        def fetch_group(group_name,depth=0):
            if depth>3: return []
            xml=("<ENVELOPE><HEADER><TALLYREQUEST>Export Data</TALLYREQUEST></HEADER>"
                 "<BODY><EXPORTDATA><REQUESTDESC>"
                 "<REPORTNAME>Group Summary</REPORTNAME>"
                 "<STATICVARIABLES>"+cmp+
                 "<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>"
                 f"<SVFROMDATE>{from_d}</SVFROMDATE><SVTODATE>{to_d}</SVTODATE>"
                 f"<SVDISPLAYFROMDATE>{from_d}</SVDISPLAYFROMDATE>"
                 f"<SVDISPLAYTODATE>{to_d}</SVDISPLAYTODATE>"
                 f"<GROUPNAME>{group_name}</GROUPNAME>"
                 "</STATICVARIABLES></REQUESTDESC></EXPORTDATA></BODY></ENVELOPE>")
            rows=[]
            try:
                raw=requests.post(f"http://localhost:{port}",data=xml.encode("utf-8"),
                                   headers={"Content-Type":"text/xml"},timeout=60).text
                self._log(f"  [{group_name}] raw response: {len(raw)} chars","#94A3B8")
                raw=re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;)','&amp;',raw)
                raw=re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]','',raw)
                if len(raw)>500:
                    root=ET.fromstring(raw) if raw.strip().startswith("<") else None
                    if root:
                        for ledger in root.iter("LEDGER"):
                            name=ledger.get("NAME","") or (ledger.findtext("NAME") or "")
                            if not name: continue
                            def gv(tag,led=ledger):
                                el=led.find(f".//{tag}")
                                if el is not None and el.text:
                                    try: return float(el.text.replace(",",""))
                                    except: return 0.0
                                return 0.0
                            rows.append({"name":name,"opening_balance":gv("OPENINGBALANCE"),"closing_balance":gv("CLOSINGBALANCE")})
                        if not rows:
                            sub_groups=[g.get("NAME","") or (g.findtext("NAME") or "") for g in root.iter("GROUP")]
                            sub_groups=[g for g in sub_groups if g and g!=group_name]
                            if sub_groups:
                                self._log(f"  [{group_name}] drilling into {len(sub_groups)} sub-group(s)",AMBER)
                                for sg in sub_groups[:15]: rows.extend(fetch_group(sg,depth+1))
            except Exception as e:
                self._log(f"  Balance Sheet trace ({group_name}) error: {e}",AMBER)
            return rows
        fixed_assets=fetch_group("Fixed Assets")
        sundry_creditors=fetch_group("Sundry Creditors")
        cg_classified=classified.get("CAPITAL_GOODS",[])
        cg_itc_claimed=sum((r.get("igst",0)+r.get("cgst",0)+r.get("sgst",0)) for r in cg_classified)
        return {
            "fixed_assets":fixed_assets,"sundry_creditors":sundry_creditors,
            "capital_goods_itc_claimed":round(cg_itc_claimed,2),
            "fixed_assets_closing_total":round(sum(r["closing_balance"] for r in fixed_assets),2),
            "sundry_creditors_outstanding_total":round(sum(r["closing_balance"] for r in sundry_creditors),2),
        }

    def _fetch_bill_wise_payments(self,all_vchs,classified):
        paid_against_bill={}; bills_found=0
        try:
            for v in all_vchs:
                vt=(v.findtext("VOUCHERTYPENAME") or v.get("VCHTYPE","")).strip().lower()
                if not any(x in vt for x in ("purchase","payment","debit note","debit-note","journal")): continue
                for list_tag in ["LEDGERENTRIES.LIST","ALLLEDGERENTRIES.LIST"]:
                    for le in v.iter(list_tag):
                        for bill in le.iter("BILLALLOCATIONS.LIST"):
                            bname=(bill.findtext("NAME") or "").strip()
                            btype=(bill.findtext("BILLTYPE") or "").strip().lower()
                            bamt=pa(bill.findtext("AMOUNT") or "0")
                            if not bname: continue
                            bills_found+=1
                            key=bname.upper().strip()
                            if "agst" in btype:
                                paid_against_bill[key]=paid_against_bill.get(key,0.0)+abs(bamt)
        except Exception as e:
            self._log(f"  Bill-wise payment fetch error: {e}",AMBER); return {}
        if bills_found==0:
            self._log("  No BILLALLOCATIONS found — bill-wise tracking likely off.","#94A3B8"); return {}
        self._log(f"  Bill-wise payments: {len(paid_against_bill)} bills with settlement data found","#94A3B8")
        return paid_against_bill


def main():
    try:
        import requests as _r
    except ImportError:
        import tkinter.messagebox as _mb
        _r2=tk.Tk(); _r2.withdraw()
        _mb.showerror("Missing Package","requests not installed.\n\nRun:\n  pip install requests"); _r2.destroy(); return
    root=tk.Tk()
    try:
        App(root); root.mainloop()
    except Exception as e:
        import traceback; err=traceback.format_exc()
        try: Path.home().joinpath("Desktop","gstagent_audit_error.txt").write_text(err)
        except: pass
        try: messagebox.showerror("Startup Error",f"Error: {str(e)}\n\nLog saved to Desktop.")
        except: pass

if __name__ == "__main__":
    main()
