#!/usr/bin/env python3
# cfe-diff v1.4 — Analyze and compare 1C configuration extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills

import argparse
import os
import re
import sys
from lxml import etree

# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
    """parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
    argv = list(sys.argv[1:] if argv is None else argv)
    names = {s.lower(): s for a in parser._actions for s in a.option_strings}
    for i, tok in enumerate(argv):
        if tok.startswith('-') and tok.lower() in names:
            argv[i] = names[tok.lower()]
    # choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
    choice_map = {}
    for a in parser._actions:
        if a.choices:
            for s in a.option_strings:
                choice_map[s] = {str(c).lower(): c for c in a.choices}
    for i in range(len(argv) - 1):
        m = choice_map.get(argv[i])
        if m and argv[i + 1].lower() in m:
            argv[i + 1] = m[argv[i + 1].lower()]
    return parser.parse_args(argv)


# --- Namespace maps ---

MD_NSMAP = {
    "md": "http://v8.1c.ru/8.3/MDClasses",
    "xr": "http://v8.1c.ru/8.3/xcf/readable",
}

FORM_NSMAP = {
    "f": "http://v8.1c.ru/8.3/xcf/logform",
}

# --- Type -> directory mapping ---

CHILD_TYPE_DIR_MAP = {
    "Catalog": "Catalogs",
    "Document": "Documents",
    "Enum": "Enums",
    "CommonModule": "CommonModules",
    "CommonPicture": "CommonPictures",
    "CommonCommand": "CommonCommands",
    "CommonTemplate": "CommonTemplates",
    "ExchangePlan": "ExchangePlans",
    "Report": "Reports",
    "DataProcessor": "DataProcessors",
    "InformationRegister": "InformationRegisters",
    "AccumulationRegister": "AccumulationRegisters",
    "ChartOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
    "ChartOfAccounts": "ChartsOfAccounts",
    "AccountingRegister": "AccountingRegisters",
    "ChartOfCalculationTypes": "ChartsOfCalculationTypes",
    "CalculationRegister": "CalculationRegisters",
    "BusinessProcess": "BusinessProcesses",
    "Task": "Tasks",
    "Subsystem": "Subsystems",
    "Role": "Roles",
    "Constant": "Constants",
    "FunctionalOption": "FunctionalOptions",
    "DefinedType": "DefinedTypes",
    "FunctionalOptionsParameter": "FunctionalOptionsParameters",
    "CommonForm": "CommonForms",
    "DocumentJournal": "DocumentJournals",
    "SessionParameter": "SessionParameters",
    "StyleItem": "StyleItems",
    "EventSubscription": "EventSubscriptions",
    "ScheduledJob": "ScheduledJobs",
    "SettingsStorage": "SettingsStorages",
    "FilterCriterion": "FilterCriteria",
    "CommandGroup": "CommandGroups",
    "DocumentNumerator": "DocumentNumerators",
    "Sequence": "Sequences",
    "IntegrationService": "IntegrationServices",
    "CommonAttribute": "CommonAttributes",
    "Style": "Styles",
    "XDTOPackage": "XDTOPackages",
    "WebService": "WebServices",
    "HTTPService": "HTTPServices",
    "WSReference": "WSReferences",
    "Bot": "Bots",
    "PaletteColor": "PaletteColors",
}


# --- Helper: check if object is borrowed ---

def get_object_info(obj_type, obj_name, extension_path):
    if obj_type not in CHILD_TYPE_DIR_MAP:
        return None
    dir_name = CHILD_TYPE_DIR_MAP[obj_type]
    obj_file = os.path.join(extension_path, dir_name, f"{obj_name}.xml")

    if not os.path.isfile(obj_file):
        return {"Borrowed": False, "File": obj_file, "Exists": False}

    parser_xml = etree.XMLParser(remove_blank_text=False)
    doc = etree.parse(obj_file, parser_xml)
    doc_root = doc.getroot()

    # Find first element child
    obj_el = None
    for c in doc_root:
        if isinstance(c.tag, str):
            obj_el = c
            break

    if obj_el is None:
        return {"Borrowed": False, "File": obj_file, "Exists": True}

    props_el = obj_el.find("md:Properties", MD_NSMAP)
    ob_node = None
    if props_el is not None:
        ob_node = props_el.find("md:ObjectBelonging", MD_NSMAP)

    borrowed = ob_node is not None and ob_node.text == "Adopted"

    return {
        "Borrowed": borrowed,
        "File": obj_file,
        "Exists": True,
        "Type": obj_type,
        "Name": obj_name,
        "DirName": dir_name,
        "ObjElement": obj_el,
    }


# --- Helper: find .bsl files for object ---

def get_bsl_files(obj_type, obj_name, extension_path):
    if obj_type not in CHILD_TYPE_DIR_MAP:
        return []
    dir_name = CHILD_TYPE_DIR_MAP[obj_type]
    obj_dir = os.path.join(extension_path, dir_name, obj_name)

    if not os.path.isdir(obj_dir):
        return []

    bsl_files = []
    ext_dir = os.path.join(obj_dir, "Ext")
    if os.path.isdir(ext_dir):
        for item in os.listdir(ext_dir):
            if item.lower().endswith(".bsl"):
                bsl_files.append(os.path.join(ext_dir, item))

    # Forms
    forms_dir = os.path.join(obj_dir, "Forms")
    if os.path.isdir(forms_dir):
        for dirpath, dirnames, filenames in os.walk(forms_dir):
            for fn in filenames:
                if fn == "Module.bsl":
                    bsl_files.append(os.path.join(dirpath, fn))

    return bsl_files


# --- Helper: parse interceptors from .bsl ---

def get_interceptors(bsl_path):
    if not os.path.isfile(bsl_path):
        return []

    with open(bsl_path, "r", encoding="utf-8-sig") as fh:
        lines = fh.readlines()

    interceptors = []
    pattern = re.compile(r'^&(\u041f\u0435\u0440\u0435\u0434|\u041f\u043e\u0441\u043b\u0435|\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c|\u0412\u043c\u0435\u0441\u0442\u043e)\("([^"]+)"\)')
    # The above is: ^&(Перед|После|ИзменениеИКонтроль|Вместо)\("([^"]+)"\)

    for i, line in enumerate(lines):
        stripped = line.strip()
        m = pattern.match(stripped)
        if m:
            interceptors.append({
                "Type": m.group(1),
                "Method": m.group(2),
                "Line": i + 1,
                "File": bsl_path,
            })

    return interceptors


# --- Helper: extract #Вставка blocks from .bsl ---

def get_insertion_blocks(bsl_path):
    if not os.path.isfile(bsl_path):
        return []

    with open(bsl_path, "r", encoding="utf-8-sig") as fh:
        lines = fh.readlines()

    blocks = []
    in_block = False
    block_lines = []
    start_line = 0

    for i, line in enumerate(lines):
        stripped = line.strip()
        if stripped == "\u0023\u0412\u0441\u0442\u0430\u0432\u043a\u0430":
            # #Вставка
            in_block = True
            block_lines = []
            start_line = i + 1
        elif stripped == "\u0023\u041a\u043e\u043d\u0435\u0446\u0412\u0441\u0442\u0430\u0432\u043a\u0438" and in_block:
            # #КонецВставки
            in_block = False
            blocks.append({
                "StartLine": start_line,
                "EndLine": i + 1,
                "Code": "\n".join(block_lines).strip(),
                "File": bsl_path,
            })
        elif in_block:
            block_lines.append(line.rstrip("\n").rstrip("\r"))

    return blocks


# --- Helper: analyze form for callType events and commands ---

def get_form_interceptors(form_xml_path):
    if not os.path.isfile(form_xml_path):
        return None

    parser_xml = etree.XMLParser(remove_blank_text=False)
    try:
        doc = etree.parse(form_xml_path, parser_xml)
    except Exception:
        return None

    f_root = doc.getroot()
    base_form = f_root.find("f:BaseForm", FORM_NSMAP)
    is_borrowed = base_form is not None

    interceptors = []

    # Form-level events with callType
    events_node = f_root.find("f:Events", FORM_NSMAP)
    if events_node is not None:
        for evt in events_node.findall("f:Event", FORM_NSMAP):
            ct = evt.get("callType", "")
            if ct:
                evt_name = evt.get("name", "")
                evt_text = evt.text or ""
                interceptors.append(f"Event:{evt_name} [{ct}] -> {evt_text}")

    # Element-level events with callType (scan all elements recursively)
    child_items = f_root.find("f:ChildItems", FORM_NSMAP)
    if child_items is not None:
        # Walk all descendant elements looking for Events/Event[@callType]
        f_ns = FORM_NSMAP["f"]
        for el in child_items.iter():
            if not isinstance(el.tag, str):
                continue
            el_name = el.get("name", "")
            if not el_name:
                continue
            events_sub = el.find(f"{{{f_ns}}}Events")
            if events_sub is None:
                continue
            for evt in events_sub.findall(f"{{{f_ns}}}Event"):
                ct = evt.get("callType", "")
                if ct:
                    evt_name = evt.get("name", "")
                    evt_text = evt.text or ""
                    interceptors.append(f"Element:{el_name}.{evt_name} [{ct}] -> {evt_text}")

    # Commands with callType on Action
    f_ns = FORM_NSMAP["f"]
    cmds_node = f_root.find(f"{{{f_ns}}}Commands")
    if cmds_node is not None:
        for cmd in cmds_node.findall(f"{{{f_ns}}}Command"):
            cmd_name = cmd.get("name", "")
            for action in cmd.findall(f"{{{f_ns}}}Action"):
                ct = action.get("callType", "")
                if ct:
                    action_text = action.text or ""
                    interceptors.append(f"Command:{cmd_name} [{ct}] -> {action_text}")

    return {
        "IsBorrowed": is_borrowed,
        "Interceptors": interceptors,
    }


# --- Mode A: Extension overview ---

def mode_a(objects, extension_path):
    borrowed_list = []
    own_list = []

    for obj in objects:
        info = get_object_info(obj["Type"], obj["Name"], extension_path)
        if info is None:
            print(f"  [?] {obj['Type']}.{obj['Name']} \u2014 unknown type")
            continue
        if not info["Exists"]:
            print(f"  [?] {obj['Type']}.{obj['Name']} \u2014 file not found")
            continue

        if info["Borrowed"]:
            borrowed_list.append(obj)

            print(f"  [BORROWED] {obj['Type']}.{obj['Name']}")

            # Find .bsl files and interceptors
            bsl_files = get_bsl_files(obj["Type"], obj["Name"], extension_path)
            for bsl in bsl_files:
                rel_path = bsl.replace(extension_path, "").lstrip("\\/")
                interceptor_list = get_interceptors(bsl)
                if len(interceptor_list) > 0:
                    for ic in interceptor_list:
                        print(f'             &{ic["Type"]}("{ic["Method"]}") \u2014 line {ic["Line"]} in {rel_path}')
                else:
                    print(f"             {rel_path} (no interceptors)")

            # Check for own attributes/forms in ChildObjects
            obj_el = info.get("ObjElement")
            if obj_el is not None:
                child_obj = obj_el.find("md:ChildObjects", MD_NSMAP)
                if child_obj is not None:
                    own_attrs = 0
                    own_forms = 0
                    own_ts = 0
                    borrowed_items = 0
                    form_names = []
                    for c in child_obj:
                        if not isinstance(c.tag, str):
                            continue
                        ln = etree.QName(c.tag).localname
                        c_props = c.find("md:Properties", MD_NSMAP)
                        if c_props is not None:
                            c_ob = c_props.find("md:ObjectBelonging", MD_NSMAP)
                            if c_ob is not None and c_ob.text == "Adopted":
                                borrowed_items += 1
                                continue
                        if ln == "Attribute":
                            own_attrs += 1
                        elif ln == "TabularSection":
                            own_ts += 1
                        elif ln == "Form":
                            form_names.append(c.text or "")
                            own_forms += 1

                    parts = []
                    if own_attrs > 0:
                        parts.append(f"{own_attrs} own attrs")
                    if own_ts > 0:
                        parts.append(f"{own_ts} own TS")
                    if own_forms > 0:
                        parts.append(f"{own_forms} own forms")
                    if borrowed_items > 0:
                        parts.append(f"{borrowed_items} borrowed items")
                    if len(parts) > 0:
                        print(f"             ChildObjects: {', '.join(parts)}")

                    # Analyze forms
                    for fn in form_names:
                        form_xml_path = os.path.join(
                            extension_path, info["DirName"], info["Name"],
                            "Forms", fn, "Ext", "Form.xml"
                        )
                        fi = get_form_interceptors(form_xml_path)
                        if fi is None:
                            print(f"             Form.{fn} (?)")
                            continue
                        form_tag = "borrowed" if fi["IsBorrowed"] else "own"
                        if len(fi["Interceptors"]) > 0:
                            print(f"             Form.{fn} ({form_tag}):")
                            for ic in fi["Interceptors"]:
                                print(f"               {ic}")
                        else:
                            print(f"             Form.{fn} ({form_tag})")
        else:
            own_list.append(obj)
            print(f"  [OWN]      {obj['Type']}.{obj['Name']}")

            # Brief info for own objects
            obj_el = info.get("ObjElement")
            if obj_el is not None:
                child_obj = obj_el.find("md:ChildObjects", MD_NSMAP)
                if child_obj is not None:
                    attrs = 0
                    forms = 0
                    ts = 0
                    for c in child_obj:
                        if not isinstance(c.tag, str):
                            continue
                        ln = etree.QName(c.tag).localname
                        if ln == "Attribute":
                            attrs += 1
                        elif ln == "TabularSection":
                            ts += 1
                        elif ln == "Form":
                            forms += 1
                    parts = []
                    if attrs > 0:
                        parts.append(f"{attrs} attrs")
                    if ts > 0:
                        parts.append(f"{ts} TS")
                    if forms > 0:
                        parts.append(f"{forms} forms")
                    if len(parts) > 0:
                        print(f"             {', '.join(parts)}")

    print("")
    print(f"=== Summary: {len(borrowed_list)} borrowed, {len(own_list)} own objects ===")


# --- Mode B: Transfer check ---

def mode_b(objects, extension_path, config_path):
    transferred = 0
    not_transferred = 0
    needs_review = 0

    for obj in objects:
        info = get_object_info(obj["Type"], obj["Name"], extension_path)
        if info is None or not info["Exists"] or not info["Borrowed"]:
            continue

        # Find .bsl files with &ИзменениеИКонтроль
        bsl_files = get_bsl_files(obj["Type"], obj["Name"], extension_path)
        for bsl in bsl_files:
            interceptor_list = get_interceptors(bsl)
            mac_interceptors = [ic for ic in interceptor_list if ic["Type"] == "\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c"]

            if len(mac_interceptors) == 0:
                continue

            for ic in mac_interceptors:
                method_name = ic["Method"]
                rel_bsl = bsl.replace(extension_path, "").lstrip("\\/")

                # Find #Вставка blocks in this file
                insert_blocks = get_insertion_blocks(bsl)

                if len(insert_blocks) == 0:
                    print(f'  [NEEDS_REVIEW] {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 no #\u0412\u0441\u0442\u0430\u0432\u043a\u0430 blocks')
                    needs_review += 1
                    continue

                # Find corresponding module in config
                if obj["Type"] not in CHILD_TYPE_DIR_MAP:
                    continue
                config_bsl = bsl.replace(extension_path, config_path)

                if not os.path.isfile(config_bsl):
                    print(f'  [NEEDS_REVIEW] {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 config module not found')
                    needs_review += 1
                    continue

                with open(config_bsl, "r", encoding="utf-8-sig") as fh:
                    config_content = fh.read()

                all_transferred = True
                for block in insert_blocks:
                    code = block["Code"]
                    if not code:
                        continue

                    # Normalize whitespace for comparison
                    code_norm = re.sub(r'\s+', ' ', code)
                    config_norm = re.sub(r'\s+', ' ', config_content)

                    if code_norm not in config_norm:
                        all_transferred = False

                if all_transferred:
                    print(f'  [TRANSFERRED]     {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 {len(insert_blocks)} block(s)')
                    transferred += 1
                else:
                    print(f'  [NOT_TRANSFERRED] {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 some blocks not found in config')
                    not_transferred += 1

    print("")
    print(f"=== Transfer check: {transferred} transferred, {not_transferred} not transferred, {needs_review} needs review ===")


# --- Main ---

def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(description="Analyze and compare 1C configuration extension (CFE)", allow_abbrev=False)
    parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
    parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
    parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
    args = ci_parse_args(parser)

    extension_path = args.ExtensionPath
    config_path = args.ConfigPath
    mode = args.Mode

    # --- Resolve paths ---
    if not os.path.isabs(extension_path):
        extension_path = os.path.join(os.getcwd(), extension_path)
    if not os.path.isabs(config_path):
        config_path = os.path.join(os.getcwd(), config_path)
    if os.path.isfile(extension_path):
        extension_path = os.path.dirname(extension_path)
    if os.path.isfile(config_path):
        config_path = os.path.dirname(config_path)

    ext_cfg = os.path.join(extension_path, "Configuration.xml")
    src_cfg = os.path.join(config_path, "Configuration.xml")
    if not os.path.isfile(ext_cfg):
        print(f"Extension Configuration.xml not found: {ext_cfg}", file=sys.stderr)
        sys.exit(1)
    if not os.path.isfile(src_cfg):
        print(f"Config Configuration.xml not found: {src_cfg}", file=sys.stderr)
        sys.exit(1)

    # --- Parse extension Configuration.xml ---
    parser_xml = etree.XMLParser(remove_blank_text=False)
    ext_doc = etree.parse(ext_cfg, parser_xml)
    ext_root = ext_doc.getroot()

    ext_props = ext_root.find(".//md:Configuration/md:Properties", MD_NSMAP)
    ext_name_node = ext_props.find("md:Name", MD_NSMAP) if ext_props is not None else None
    ext_name = ext_name_node.text if ext_name_node is not None and ext_name_node.text else "?"
    prefix_node = ext_props.find("md:NamePrefix", MD_NSMAP) if ext_props is not None else None
    name_prefix = prefix_node.text if prefix_node is not None and prefix_node.text else ""
    purpose_node = ext_props.find("md:ConfigurationExtensionPurpose", MD_NSMAP) if ext_props is not None else None
    purpose = purpose_node.text if purpose_node is not None and purpose_node.text else "?"

    print(f"=== cfe-diff Mode {mode}: {ext_name} ({purpose}) ===")
    print(f"    NamePrefix: {name_prefix}")
    print("")

    # --- Collect ChildObjects ---
    child_obj_node = ext_root.find(".//md:Configuration/md:ChildObjects", MD_NSMAP)
    if child_obj_node is None:
        print("[WARN] No ChildObjects in extension")
        sys.exit(0)

    objects = []
    for child in child_obj_node:
        if not isinstance(child.tag, str):
            continue
        ln = etree.QName(child.tag).localname
        if ln == "Language":
            continue
        objects.append({"Type": ln, "Name": child.text or ""})

    if len(objects) == 0:
        print("No objects (besides Language) in extension.")
        sys.exit(0)

    # --- Run selected mode ---
    if mode == "A":
        mode_a(objects, extension_path)
    elif mode == "B":
        mode_b(objects, extension_path, config_path)


if __name__ == "__main__":
    main()
