#!/usr/bin/env python3
"""
L5X RLL Text-Format Parser

Parses the compact text rung format from Allen-Bradley .L5X exports into
structured data (Networks → Rungs → Branches → Elements).

Text format grammar:
  Rung ::= (Instruction | Branch)* ';'
  Branch ::= '[' BranchPath (',' BranchPath)* ']'
  BranchPath ::= Instruction*
  Instruction ::= OTE | OTL | OTU | XIC | XIO | TON | TOF | RTO | CTU | CTD |
                 RES | MOV | COP | BTD | LIM | CPT | EQU | NEQ | GRT | LES |
                 GEQ | LEQ | ADD | SUB | MUL | DIV | NOP | MCR | JSR | SRT |
                 SST | CLR | OTS | FLL | SWPB | FBC | GSV | Z_* | Technifor_EIP
                 | AnyUnknown3Char

Contacts (inline logic): XIC, XIO
Outputs (right rail): OTE, OTL, OTU
Function blocks: everything else
"""

import re
import xml.etree.ElementTree as ET
from typing import List, Dict, Any, Optional


# ── Element classification ───────────────────────────────────────────

CONTACTS = {'XIC', 'XIO'}
COILS = {'OTE', 'OTL', 'OTU', 'OTS'}
TIMER_TYPES = {'TON', 'TOF', 'RTO'}
COUNTER_TYPES = {'CTU', 'CTD', 'CTUD'}
COMPARISONS = {'EQU', 'NEQ', 'GRT', 'LES', 'GEQ', 'LEQ', 'LIM'}
DATA_OPS = {'MOV', 'COP', 'BTD', 'CPT', 'CLR', 'FLL', 'SWPB', 'EXG', 'FBC', 'GSV',
            'SQT', 'ABS', 'NEG', 'INC', 'DEC'}
MATH_OPS = {'ADD', 'SUB', 'MUL', 'DIV'}
CONTROL_OPS = {'NOP', 'MCR', 'JSR', 'SRT', 'SST', 'RES', 'JMP', 'LBL', 'ONS', 'OSR', 'OSF'}

ALL_TYPES = CONTACTS | COILS | TIMER_TYPES | COUNTER_TYPES | COMPARISONS | DATA_OPS | MATH_OPS | CONTROL_OPS


# ── Tokenizer ────────────────────────────────────────────────────────

def tokenize_rung(text: str) -> List[str]:
    """
    Split rung text into individual instruction tokens.
    Handles: INST(args), INST(args)INST2(args2), [branch1,branch2], nested [...]
    """
    tokens = []
    i = 0
    text = text.strip().rstrip(';').strip()
    
    while i < len(text):
        if text[i] == ' ':
            i += 1
            continue
        
        if text[i] == '[':
            # Find matching closing bracket
            depth = 0
            start = i
            while i < len(text):
                if text[i] == '[':
                    depth += 1
                elif text[i] == ']':
                    depth -= 1
                    if depth == 0:
                        break
                i += 1
            tokens.append(text[start:i+1])
            i += 1
            continue
        
        if text[i] == ',':
            tokens.append(',')
            i += 1
            continue
        
        # Match instruction: NAME(args) or NAME();
        match = re.match(r'([A-Za-z_][A-Za-z0-9_]*)(?:\(([^)]*)\))?', text[i:])
        if match:
            name = match.group(1)
            args_str = match.group(2)
            if name in ALL_TYPES or (len(name) <= 3 and name.isupper()):
                tokens.append(f"{name}({args_str})" if args_str is not None else name)
                i += match.end()
                continue
        
        # Skip unknown characters
        i += 1
    
    return tokens


# ── Instruction Parser ───────────────────────────────────────────────

def parse_instruction(token: str) -> Dict[str, Any]:
    """
    Parse a single instruction token into a structured element dict.
    
    Returns:
        {
            'type': 'XIC',
            'comment': 'tag_name',
            'tag': 'tag_name',
            'value': value,        # for comparisons
            'params': [args],      # raw parameter list
            'is_contact': bool,
            'is_coil': bool,
            'is_block': bool
        }
    """
    # Extract name and args
    match = re.match(r'([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?', token)
    if not match:
        return {'type': 'UNKNOWN', 'comment': token, 'params': [], 'is_block': True}
    
    name = match.group(1)
    args_str = match.group(2) or ''
    params = [p.strip() for p in args_str.split(',') if p.strip()] if args_str else []
    
    result = {
        'type': name,
        'params': params,
    }
    
    if name in CONTACTS:
        result['is_contact'] = True
        result['is_coil'] = False
        result['is_block'] = False
        result['tag'] = params[0] if params else ''
        result['comment'] = params[0] if params else ''
    
    elif name in COILS:
        result['is_contact'] = False
        result['is_coil'] = True
        result['is_block'] = False
        result['tag'] = params[0] if params else ''
        result['comment'] = params[0] if params else ''
    
    elif name in TIMER_TYPES:
        result['is_contact'] = False
        result['is_coil'] = False
        result['is_block'] = True
        result['tag'] = params[0] if params else ''
        result['comment'] = params[0] if params else ''
        # TON(timer, preset, base) — preset may be '?' in L5X
        if len(params) >= 2:
            try:
                result['preset'] = int(params[1]) if params[1] != '?' else 0
            except ValueError:
                result['preset'] = 0
    
    elif name in COUNTER_TYPES:
        result['is_contact'] = False
        result['is_coil'] = False
        result['is_block'] = True
        result['tag'] = params[0] if params else ''
        result['comment'] = params[0] if params else ''
        if len(params) >= 2:
            try:
                result['preset'] = int(params[1]) if params[1] != '?' else 0
            except ValueError:
                result['preset'] = 0
    
    elif name in COMPARISONS:
        result['is_contact'] = True  # comparisons act as contacts
        result['is_coil'] = False
        result['is_block'] = False
        result['tag'] = params[0] if len(params) >= 1 else ''
        if len(params) >= 2:
            try:
                result['value'] = int(params[1])
            except ValueError:
                try:
                    result['value'] = float(params[1])
                except ValueError:
                    result['value'] = params[1]
        if len(params) >= 3:
            try:
                result['value2'] = int(params[2])
            except ValueError:
                try:
                    result['value2'] = float(params[2])
                except ValueError:
                    result['value2'] = params[2]
        result['comment'] = name
    
    elif name in DATA_OPS | MATH_OPS | CONTROL_OPS:
        result['is_contact'] = False
        result['is_coil'] = False
        result['is_block'] = True
        result['tag'] = params[0] if params else ''
        result['comment'] = name
        # Store additional params as value/value2
        if len(params) >= 2:
            try:
                result['value'] = int(params[1])
            except ValueError:
                result['value'] = params[1]
        if len(params) >= 3:
            try:
                result['value2'] = int(params[2])
            except ValueError:
                result['value2'] = params[2]
    
    else:
        # Unknown instruction — treat as function block
        result['is_contact'] = False
        result['is_coil'] = False
        result['is_block'] = True
        result['tag'] = params[0] if params else ''
        result['comment'] = name
    
    return result


# ── Branch Parser ────────────────────────────────────────────────────

def parse_branch_group(text: str) -> List[List[Dict[str, Any]]]:
    """
    Parse a bracket-enclosed branch group: [path1 ,path2 ,path3]
    
    Returns list of branches, each branch is a list of elements.
    """
    # Strip outer brackets
    inner = text.strip()[1:-1].strip()
    
    # Split by comma at top level (not inside brackets)
    paths = []
    depth = 0
    current = []
    for ch in inner:
        if ch == '[':
            depth += 1
            current.append(ch)
        elif ch == ']':
            depth -= 1
            current.append(ch)
        elif ch == ',' and depth == 0:
            paths.append(''.join(current))
            current = []
        else:
            current.append(ch)
    
    if current:
        paths.append(''.join(current))
    
    branches = []
    for path in paths:
        path = path.strip()
        if not path:
            continue
        
        # Tokenize and parse this path
        tokens = tokenize_rung(path)
        elements = []
        for tok in tokens:
            if tok == ',':
                continue
            if tok.startswith('['):
                # Nested branch
                nested = parse_branch_group(tok)
                # Flatten nested for simplicity — add as a sub-branch marker
                for sub_branch in nested:
                    elements.extend(sub_branch)
            else:
                elem = parse_instruction(tok)
                elements.append(elem)
        branches.append(elements)
    
    return branches


# ── Rung Parser ──────────────────────────────────────────────────────

def parse_rung(text: str) -> Dict[str, Any]:
    """
    Parse a complete rung text into structured data.
    
    Returns:
        {
            'branches': [[elements], [elements], ...],  # parallel branches
            'serial': [elements],                        # elements after branches
            'raw': original_text
        }
    """
    text = text.strip().rstrip(';').strip()
    if not text or text == 'NOP':
        return {'branches': [], 'serial': [], 'raw': text}
    
    tokens = tokenize_rung(text)
    
    # Group tokens: before-branches (serial), branches, after-branches (serial)
    before_serial = []
    branch_groups = []
    after_serial = []
    
    in_branches = False
    current_branch_group = []
    
    i = 0
    while i < len(tokens):
        tok = tokens[i]
        
        if tok.startswith('['):
            in_branches = True
            # Find the complete branch group token
            current_branch_group = [tok]
            branch_groups.append(parse_branch_group(tok))
            i += 1
        elif tok == ',' and in_branches:
            # Comma between branches at top level — handled by parse_branch_group
            i += 1
        elif tok.startswith(']') and in_branches:
            # End of branch group
            in_branches = False
            i += 1
        else:
            elem = parse_instruction(tok)
            if in_branches:
                # Elements after closing bracket but before next branch
                after_serial.append(elem)
            else:
                before_serial.append(elem)
            i += 1
    
    # Combine: if there are branches, the before_serial are lead-in contacts
    # and after_serial are trailing elements (coils, blocks)
    # Flatten branch groups: each group is [[path1_elements], [path2_elements]]
    # We want a flat list of all branch paths across all groups
    flat_branches = []
    for group in branch_groups:
        flat_branches.extend(group)

    result = {
        'lead_in': before_serial,
        'branches': flat_branches,
        'trailing': after_serial,
        'raw': text
    }
    
    return result


# ── L5X File Parser ──────────────────────────────────────────────────

def parse_l5x_programs(l5x_path: str) -> Dict[str, Any]:
    """
    Parse an L5X file and extract the full Program → Routine → Rung hierarchy.
    
    Returns:
        {
            'programs': [
                {
                    'name': program_name,
                    'routines': [
                        {
                            'name': routine_name,
                            'type': 'RLL' | 'LAD',
                            'rung_count': int,
                            'rungs': [parsed_rung_dict, ...]
                        }
                    ],
                    'routine_count': int
                }
            ],
            'program_count': int
        }
    """
    tree = ET.parse(l5x_path)
    root = tree.getroot()
    
    programs = []
    
    # Navigate: Controller → Programs → Program
    controller = root.find('Controller')
    if controller is None:
        return {'programs': [], 'program_count': 0}
    
    programs_el = controller.find('Programs')
    if programs_el is None:
        return {'programs': [], 'program_count': 0}
    
    for program in programs_el:
        program_name = program.get('Name')
        if not program_name:
            name_el = program.find('Name')
            program_name = name_el.text if name_el is not None and name_el.text else 'Unknown'
        
        # Find Routines within this Program
        routines_el = program.find('Routines')
        if routines_el is None:
            continue
        
        routine_list = []
        for routine in routines_el:
            routine_name = routine.get('Name')
            if not routine_name:
                name_el = routine.find('Name')
                routine_name = name_el.text if name_el is not None and name_el.text else 'Unknown'
            
            lang = routine.get('Type')
            if not lang:
                lang_el = routine.find('Language')
                lang = lang_el.text if lang_el is not None and lang_el.text else 'Unknown'
            
            # Accept both 'RLL' and 'LAD' as ladder logic types
            if lang not in ('RLL', 'LAD'):
                continue
            
            # For RLL, rungs are in RLLContent
            rung_list = []
            content_el = routine.find('RLLContent')
            if content_el is not None:
                for rung in content_el:
                    if rung.tag != 'Rung':
                        continue
                    
                    num_str = rung.get('Number')
                    if not num_str:
                        num_el = rung.find('Number')
                        num_str = num_el.text if num_el is not None and num_el.text else '0'
                    
                    comment_el = rung.find('Comment')
                    comment = comment_el.text if comment_el is not None and comment_el.text else ''
                    
                    text_el = rung.find('Text')
                    text = text_el.text if text_el is not None else ''
                    text = text.strip().rstrip(';').strip()
                    
                    num = int(num_str) if num_str else 0
                    parsed = parse_rung(text)
                    
                    rung_list.append({
                        'number': num,
                        'comment': comment,
                        'parsed': parsed,
                        'raw_text': text
                    })
            
            rung_list.sort(key=lambda r: r['number'])
            
            routine_list.append({
                'name': routine_name,
                'type': lang,
                'rung_count': len(rung_list),
                'rungs': rung_list,
            })
        
        if routine_list:
            programs.append({
                'name': program_name,
                'routines': routine_list,
                'routine_count': len(routine_list),
            })
    
    return {
        'programs': programs,
        'program_count': len(programs),
    }


def parse_l5x_routines(l5x_path: str) -> List[Dict[str, Any]]:
    """
    Parse an L5X file and extract all RLL routines with their rungs.
    
    Legacy flat list — prefer parse_l5x_programs() for full hierarchy.
    """
    result = parse_l5x_programs(l5x_path)
    routines = []
    for prog in result['programs']:
        for rout in prog['routines']:
            routines.append({
                'program': prog['name'],
                'name': rout['name'],
                'language': rout['type'],
                'rung_count': rout['rung_count'],
                'rungs': rout['rungs'],
            })
    return routines


def parse_l5x_to_program(l5x_path: str, routine_index: int = 0) -> Dict[str, Any]:
    """
    Parse L5X and return the first (or specified) routine as a program dict
    compatible with the existing ladder interpreter format.
    """
    routines = parse_l5x_routines(l5x_path)
    if not routines:
        return {'name': 'Empty', 'networks': []}
    
    routine = routines[routine_index]
    
    # Convert to Networks format for the ladder interpreter
    networks = []
    for rung in routine['rungs']:
        parsed = rung['parsed']
        
        # Build elements list
        elements = list(parsed.get('lead_in', []))
        
        # If there are branches, represent as parallel paths
        branches = parsed.get('branches', [])
        trailing = parsed.get('trailing', [])
        
        network = {
            'number': rung['number'],
            'comment': rung['comment'],
            'elements': elements,
            'parallel': branches,
            'outputs': trailing,
            'raw': rung['raw_text']
        }
        networks.append(network)
    
    return {
        'name': routine['name'],
        'networks': networks,
        'routine_count': len(routines),
        'all_routines': [r['name'] for r in routines],
    }
