#!/usr/bin/env python3
# subsystem-validate v1.5 — Validate 1C subsystem XML structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates subsystem XML file structure, properties, content items, child objects."""
import sys, os, argparse, re
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)


NS = {
    'md':  'http://v8.1c.ru/8.3/MDClasses',
    'v8':  'http://v8.1c.ru/8.1/data/core',
    'xr':  'http://v8.1c.ru/8.3/xcf/readable',
    'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
}

GUID_PATTERN = re.compile(
    r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
)
IDENT_PATTERN = re.compile(
    r'^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_]'
    r'[A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
)

KNOWN_PLURAL_TYPES = {
    'Catalogs', 'Documents', 'Enums', 'Constants', 'Reports', 'DataProcessors',
    'InformationRegisters', 'AccumulationRegisters', 'AccountingRegisters', 'CalculationRegisters',
    'ChartsOfAccounts', 'ChartsOfCharacteristicTypes', 'ChartsOfCalculationTypes',
    'BusinessProcesses', 'Tasks', 'ExchangePlans', 'DocumentJournals',
    'CommonModules', 'CommonCommands', 'CommonForms', 'CommonPictures', 'CommonTemplates',
    'CommonAttributes', 'CommandGroups', 'Roles', 'SessionParameters', 'FilterCriteria',
    'XDTOPackages', 'WebServices', 'HTTPServices', 'WSReferences', 'EventSubscriptions',
    'ScheduledJobs', 'SettingsStorages', 'FunctionalOptions', 'FunctionalOptionsParameters',
    'DefinedTypes', 'DocumentNumerators', 'Sequences', 'Subsystems', 'StyleItems', 'IntegrationServices',
}


class Reporter:
    def __init__(self, max_errors, detailed=False):
        self.errors = 0
        self.warnings = 0
        self.ok_count = 0
        self.stopped = False
        self.max_errors = max_errors
        self.detailed = detailed
        self.lines = []

    def out(self, msg=''):
        self.lines.append(msg)

    def ok(self, msg):
        self.ok_count += 1
        if self.detailed:
            self.lines.append(f'[OK]    {msg}')

    def error(self, msg):
        self.errors += 1
        self.lines.append(f'[ERROR] {msg}')
        if self.errors >= self.max_errors:
            self.stopped = True

    def warn(self, msg):
        self.warnings += 1
        self.lines.append(f'[WARN]  {msg}')

    def text(self):
        return '\r\n'.join(self.lines) + '\r\n'


def find_duplicates(items):
    seen = {}
    dupes = []
    for item in items:
        seen[item] = seen.get(item, 0) + 1
    for item, count in seen.items():
        if count > 1 and item not in dupes:
            dupes.append(item)
    return dupes


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(
        description='Validate 1C subsystem XML structure', allow_abbrev=False
    )
    parser.add_argument('-SubsystemPath', '-Path', dest='SubsystemPath', required=True)
    parser.add_argument('-Detailed', action='store_true')
    parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
    parser.add_argument('-OutFile', dest='OutFile', default='')
    args = ci_parse_args(parser)

    subsystem_path = args.SubsystemPath
    detailed = args.Detailed
    max_errors = args.MaxErrors
    out_file = args.OutFile

    # --- Resolve path ---
    if not os.path.isabs(subsystem_path):
        subsystem_path = os.path.join(os.getcwd(), subsystem_path)

    if os.path.isdir(subsystem_path):
        dir_name = os.path.basename(subsystem_path)
        candidate = os.path.join(subsystem_path, dir_name + '.xml')
        sibling = os.path.join(os.path.dirname(subsystem_path), dir_name + '.xml')
        if os.path.exists(candidate):
            subsystem_path = candidate
        elif os.path.exists(sibling):
            subsystem_path = sibling
        else:
            print(f'[ERROR] No {dir_name}.xml found in directory: {subsystem_path}')
            sys.exit(1)

    # File not found -- check Dir/Name/Name.xml -> Dir/Name.xml
    if not os.path.exists(subsystem_path):
        fn = os.path.splitext(os.path.basename(subsystem_path))[0]
        pd = os.path.dirname(subsystem_path)
        if fn == os.path.basename(pd):
            c = os.path.join(os.path.dirname(pd), fn + '.xml')
            if os.path.exists(c):
                subsystem_path = c

    if not os.path.exists(subsystem_path):
        print(f'[ERROR] File not found: {subsystem_path}')
        sys.exit(1)

    resolved_path = os.path.abspath(subsystem_path)
    r = Reporter(max_errors, detailed)

    # --- 1. XML well-formedness + root structure ---
    xml_doc = None
    try:
        xml_parser = etree.XMLParser(remove_blank_text=False)
        xml_doc = etree.parse(resolved_path, xml_parser)
    except etree.XMLSyntaxError as e:
        r.error(f'1. XML parse error: {e}')
        r.stopped = True

    sub = None
    version = ''
    if not r.stopped:
        root = xml_doc.getroot()
        version = root.get('version', '')
        sub_list = root.findall('md:Subsystem', NS)
        sub = sub_list[0] if sub_list else None

        if sub is None:
            r.error('1. Root structure: expected MetaDataObject/Subsystem, not found')
            r.stopped = True
        else:
            uuid_val = sub.get('uuid', '')
            if uuid_val and GUID_PATTERN.match(uuid_val):
                r.ok(f'1. Root structure: MetaDataObject/Subsystem, uuid={uuid_val}, version {version}')
            else:
                r.error('1. Root structure: invalid or missing uuid')

    # --- Properties checks ---
    props = None
    if not r.stopped:
        props_list = sub.findall('md:Properties', NS)
        props = props_list[0] if props_list else None
        if props is None:
            r.error('2. Properties: <Properties> element not found')
            r.stopped = True

    sub_name = ''
    if not r.stopped:
        # --- 2. Required properties ---
        required_props = [
            'Name', 'Synonym', 'Comment', 'IncludeHelpInContents',
            'IncludeInCommandInterface', 'UseOneCommand', 'Explanation',
            'Picture', 'Content'
        ]
        missing = []
        for p in required_props:
            el = props.find(f'md:{p}', NS)
            if el is None:
                missing.append(p)

        if len(missing) == 0:
            r.ok('2. Properties: all 9 required properties present')
        else:
            r.error(f'2. Properties: missing: {", ".join(missing)}')

        # --- 3. Name ---
        name_el = props.find('md:Name', NS)
        sub_name = (name_el.text or '').strip() if name_el is not None else ''

        r.out('')
        r.out(f'=== Validation: Subsystem.{sub_name} ===')
        # Re-insert header at position 0
        header_line = f'=== Validation: Subsystem.{sub_name} ==='
        r.lines.insert(0, '')
        r.lines.insert(0, header_line)

        if sub_name and IDENT_PATTERN.match(sub_name):
            r.ok(f'3. Name: "{sub_name}" - valid identifier')
        elif not sub_name:
            r.error('3. Name: empty')
        else:
            r.error(f'3. Name: "{sub_name}" - invalid identifier')

        # --- 4. Synonym ---
        syn_el = props.find('md:Synonym', NS)
        if syn_el is not None and len(syn_el) > 0:
            items = syn_el.findall('v8:item', NS)
            if len(items) > 0:
                first_content = ''
                for item in items:
                    c = item.find('v8:content', NS)
                    if c is not None and c.text:
                        first_content = c.text
                        break
                r.ok(f'4. Synonym: "{first_content}" ({len(items)} lang(s))')
            else:
                r.warn('4. Synonym: element exists but no v8:item children')
        else:
            r.warn('4. Synonym: empty or missing')

        # --- 5. Boolean properties ---
        bool_props = ['IncludeHelpInContents', 'IncludeInCommandInterface', 'UseOneCommand']
        bool_ok = True
        bool_vals = {}
        for bp in bool_props:
            el = props.find(f'md:{bp}', NS)
            if el is not None:
                val = (el.text or '').strip()
                bool_vals[bp] = val
                if val not in ('true', 'false'):
                    r.error(f'5. Boolean property {bp} = "{val}" (expected true/false)')
                    bool_ok = False
        if bool_ok:
            r.ok('5. Boolean properties: valid')

        # --- 6. Content items format ---
        content_el = props.find('md:Content', NS)
        content_items = []
        if content_el is not None and len(content_el) > 0:
            xr_items = content_el.findall('xr:Item', NS)
            content_ok = True
            for item in xr_items:
                type_attr = item.get(f'{{{NS["xsi"]}}}type', '')
                text = (item.text or '').strip()
                content_items.append(text)
                if type_attr != 'xr:MDObjectRef':
                    r.error(f'6. Content item "{text}": xsi:type="{type_attr}" (expected xr:MDObjectRef)')
                    content_ok = False
                if not re.match(r'^[A-Za-z]+\..+$', text) and not GUID_PATTERN.match(text):
                    r.error(f'6. Content item "{text}": invalid format (expected Type.Name or UUID)')
                    content_ok = False
                m = re.match(r'^([A-Za-z]+)\.', text)
                if m and m.group(1) in KNOWN_PLURAL_TYPES:
                    r.error(f'6. Content item "{text}": uses plural form "{m.group(1)}" (platform requires singular, e.g. Catalog not Catalogs)')
                    content_ok = False
            if content_ok:
                r.ok(f'6. Content: {len(xr_items)} items, all valid MDObjectRef format')
        else:
            r.ok('6. Content: empty (no items)')

        # --- 7. Content duplicates ---
        if len(content_items) > 0:
            dupes = find_duplicates(content_items)
            if dupes:
                r.warn(f'7. Content: duplicates found: {", ".join(dupes)}')
            else:
                r.ok('7. Content: no duplicates')

        # --- 8. ChildObjects entries non-empty ---
        child_objs = sub.find('md:ChildObjects', NS)
        child_names = []
        if child_objs is not None and len(child_objs) > 0:
            child_ok = True
            for child in child_objs:
                if not isinstance(child.tag, str):
                    continue
                local_name = etree.QName(child.tag).localname
                if local_name != 'Subsystem':
                    r.error(f'8. ChildObjects: unexpected element <{local_name}>')
                    child_ok = False
                elif not (child.text or '').strip():
                    r.error('8. ChildObjects: empty <Subsystem> element')
                    child_ok = False
                else:
                    child_names.append((child.text or '').strip())
            if child_ok:
                r.ok(f'8. ChildObjects: {len(child_names)} entries, all non-empty')
        else:
            r.ok('8. ChildObjects: empty (leaf subsystem)')

        # --- 9. ChildObjects duplicates ---
        if len(child_names) > 0:
            dupes = find_duplicates(child_names)
            if dupes:
                r.error(f'9. ChildObjects: duplicates: {", ".join(dupes)}')
            else:
                r.ok('9. ChildObjects: no duplicates')

        # --- 10. ChildObjects files exist ---
        if len(child_names) > 0:
            parent_dir = os.path.dirname(resolved_path)
            base_name = os.path.splitext(os.path.basename(resolved_path))[0]
            subs_dir = os.path.join(parent_dir, base_name, 'Subsystems')
            missing_files = []
            for cn in child_names:
                child_xml = os.path.join(subs_dir, cn + '.xml')
                if not os.path.exists(child_xml):
                    missing_files.append(cn)
            if len(missing_files) == 0:
                r.ok(f'10. ChildObjects files: all {len(child_names)} files exist')
            else:
                r.warn(f'10. ChildObjects files: missing: {", ".join(missing_files)}')

        # --- 11. CommandInterface.xml ---
        parent_dir2 = os.path.dirname(resolved_path)
        base_name2 = os.path.splitext(os.path.basename(resolved_path))[0]
        ci_path = os.path.join(parent_dir2, base_name2, 'Ext', 'CommandInterface.xml')
        if os.path.exists(ci_path):
            try:
                etree.parse(ci_path, etree.XMLParser(remove_blank_text=False))
                r.ok('11. CommandInterface: exists, well-formed')
            except etree.XMLSyntaxError as e:
                r.warn(f'11. CommandInterface: exists but NOT well-formed: {e}')
        else:
            r.ok('11. CommandInterface: not present')

        # --- 12. Picture format ---
        pic_el = props.find('md:Picture', NS)
        if pic_el is not None and len(pic_el) > 0:
            pic_ref = pic_el.find('xr:Ref', NS)
            if pic_ref is not None and pic_ref.text:
                ref_text = pic_ref.text
                if ref_text.startswith('CommonPicture.'):
                    r.ok(f'12. Picture: {ref_text}')
                else:
                    r.warn(f'12. Picture: "{ref_text}" (expected CommonPicture.XXX)')
            else:
                r.warn('12. Picture: has children but no xr:Ref content')
        else:
            r.ok('12. Picture: empty (not set)')

        # --- 13. UseOneCommand constraint ---
        use_one = bool_vals.get('UseOneCommand', '')
        if use_one == 'true':
            if len(content_items) == 1:
                r.ok('13. UseOneCommand: true, Content has exactly 1 item')
            else:
                r.warn(f'13. UseOneCommand: true but Content has {len(content_items)} items (expected 1)')
        else:
            r.ok('13. UseOneCommand: false (no constraint)')

    # --- Finalize ---
    checks = r.ok_count + r.errors + r.warnings
    if r.errors == 0 and r.warnings == 0 and not detailed:
        result = f'=== Validation OK: Subsystem.{sub_name} ({checks} checks) ==='
    else:
        r.out('')
        r.out(f'=== Result: {r.errors} errors, {r.warnings} warnings ({checks} checks) ===')
        result = '\r\n'.join(r.lines) + '\r\n'

    print(result, end='')

    if out_file:
        if not os.path.isabs(out_file):
            out_file = os.path.join(os.getcwd(), out_file)
        with open(out_file, 'w', encoding='utf-8-sig', newline='') as f:
            f.write(result)
        print(f'Written to: {out_file}')

    sys.exit(1 if r.errors > 0 else 0)


if __name__ == '__main__':
    main()
