"""Keep IMM5710 contact widgets consistent with confirmed dataset selections."""
import re
from xml.parsers import expat

ADDRESS_SELECTION = 'ContactInformation.Resi.SameAsAddr.SameAsMailingInd'
RESIDENTIAL_FIELDS = [
    'AddrLine1.AptUnit', 'AddrLine1.StreetNum', 'AddrLine1.Streetname',
    'AddrLine2.City', 'AddrLine2.Country', 'AddrLine2.Prov', 'AddrLine2.PostalCode', 'AddrLine2.District',
]


def address_values(fields):
    """Clear inactive residential values without changing caller answers."""
    result = dict(fields)
    if fields.get(ADDRESS_SELECTION) == 'Y':
        for name in RESIDENTIAL_FIELDS:
            result['ContactInformation.Resi.' + name] = ''
    return result


def contact_address_state(xml_bytes, fields):
    """Patch saved XFA state, preserving scripts, namespaces and PIs byte for byte."""
    choice = fields.get(ADDRESS_SELECTION)
    if choice not in ('Y', 'N'):
        return xml_bytes
    parser = expat.ParserCreate(namespace_separator='}')
    stack, nodes = [], []

    def start(tag, attrs):
        path = stack[-1]['path'] if stack else ''
        if attrs.get('name'):
            path = (path + '.' + attrs['name']).lstrip('.')
        offset = parser.CurrentByteIndex
        match = re.match(br'<(?:[^>"\']|"[^"]*"|\'[^\']*\')*>', xml_bytes[offset:])
        node = dict(tag=tag.rsplit('}', 1)[-1], path=path, start=offset,
                    opening_end=offset + match.end(), children=[])
        if stack:
            stack[-1]['children'].append(node)
        stack.append(node)
        nodes.append(node)

    def end(tag):
        node = stack.pop()
        node['end'] = (node['opening_end'] if xml_bytes[node['opening_end']-2:node['opening_end']] == b'/>'
                       else xml_bytes.index(b'>', parser.CurrentByteIndex) + 1)
    parser.StartElementHandler, parser.EndElementHandler = start, end
    parser.Parse(xml_bytes, True)
    edits = []
    group = 'form1.Page2.' + ADDRESS_SELECTION
    residential = 'form1.Page2.ContactInformation.Resi.'
    # Saved form packets omit unchanged widgets. Materialize only address state.
    if nodes[0]['tag'] == 'form':
        by_path = {n['path']: n for n in nodes if n['tag'] in ('subform', 'field', 'exclGroup')}
        additions = {}
        if group not in by_path:
            additions[residential.rstrip('.')] = (
                '<subform name="SameAsAddr"><exclGroup name="SameAsMailingInd">'
                '<field name="No"><value><text/></value></field>'
                '<field name="Yes"><value><text/></value></field>'
                '</exclGroup></subform>')
        for name in RESIDENTIAL_FIELDS:
            if residential + name not in by_path:
                parent, field = name.rsplit('.', 1)
                key = residential + parent
                additions[key] = additions.get(key, '') + '<field name="' + field + '"><value><text/></value><validate nullTest="disabled"/></field>'
        if additions:
            for path, content in additions.items():
                parent = by_path[path]
                offset = parent['opening_end']
                edits.append((offset, offset, content.encode()))
            for begin, finish, replacement in sorted(edits, reverse=True):
                xml_bytes = xml_bytes[:begin] + replacement + xml_bytes[finish:]
            return contact_address_state(xml_bytes, fields)

    def attribute(node, name, value):
        opening = xml_bytes[node['start']:node['opening_end']]
        pattern = rb'\b' + name.encode() + rb'\s*=\s*["\'][^"\']*["\']'
        replacement = name.encode() + b'="' + value.encode() + b'"'
        if re.search(pattern, opening):
            opening = re.sub(pattern, replacement, opening, count=1)
        else:
            pos = len(opening) - (2 if opening.endswith(b'/>') else 1)
            opening = opening[:pos] + b' ' + replacement + opening[pos:]
        edits.append((node['start'], node['opening_end'], opening))

    def prefix_for(node):
        opening = xml_bytes[node['start']:node['opening_end']]
        return re.match(br'<((?:[^: >]+:)?)[^: >]+', opening)[1]

    def value(node, text):
        opening = xml_bytes[node['start']:node['opening_end']]
        prefix = prefix_for(node)
        content = b'<' + prefix + b'value><' + prefix + b'text>' + text.encode() + b'</' + prefix + b'text></' + prefix + b'value>'
        existing = next((c for c in node['children'] if c['tag'] == 'value'), None)
        if existing:
            edits.append((existing['start'], existing['end'], content))
        elif not opening.endswith(b'/>'):
            edits.append((node['opening_end'], node['opening_end'], content))

    for node in nodes:
        if node['path'] == group and node['tag'] == 'exclGroup':
            value(node, choice)
        elif node['tag'] == 'field' and node['path'] in (group + '.Yes', group + '.No'):
            selected = 'Y' if node['path'].endswith('.Yes') else 'N'
            value(node, selected if choice == selected else '')
        elif node['tag'] == 'field' and node['path'] in [residential + n for n in RESIDENTIAL_FIELDS]:
            attribute(node, 'access', 'readOnly' if choice == 'Y' or node['path'].endswith('.Country') else 'open')
            if choice == 'Y':
                value(node, '')
            for child in node['children']:
                if child['tag'] == 'validate':
                    required = not node['path'].endswith(('.AptUnit', '.StreetNum', '.District'))
                    attribute(child, 'nullTest', 'error' if choice == 'N' and required else 'disabled')
            if not any(c['tag'] == 'validate' for c in node['children']):
                required = choice == 'N' and not node['path'].endswith(('.AptUnit', '.StreetNum', '.District'))
                content = b'<' + prefix_for(node) + b'validate nullTest="' + (b'error' if required else b'disabled') + b'"/>'
                edits.append((node['opening_end'], node['opening_end'], content))
    for begin, finish, replacement in sorted(edits, key=lambda e: (e[0], e[1]), reverse=True):
        xml_bytes = xml_bytes[:begin] + replacement + xml_bytes[finish:]
    return xml_bytes


def contact_phone_state(xml_bytes: bytes, fields: dict[str, str]) -> bytes:
    states = {}
    for group, section in [('Phone', 'q3-4'), ('AltPhone', 'q3-4'), ('Fax', 'q5-6')]:
        prefix = f'ContactInformation.{section}.{group}.'
        canada = fields.get(prefix + 'CanOtherInd.CanadaUS')
        other = fields.get(prefix + 'CanOtherInd.Other')
        if canada == '1' and other == '1':
            raise ValueError('Contradictory IMM5710 phone selections')
        if (canada == '1' and other in (None, '0')) or (other == '1' and canada in (None, '0')):
            states['form1.Page2.' + prefix + 'NANumber'] = 'visible' if canada == '1' else 'invisible'
            states['form1.Page2.' + prefix + 'IntlNumber'] = 'invisible' if canada == '1' else 'visible'
    if not states:
        return xml_bytes

    # XFA packets contain processing instructions and namespace-sensitive content.
    # Locate attributes with a parser, but preserve every other original byte.
    parser = expat.ParserCreate(namespace_separator='}')
    paths = []
    edits = []

    def start(tag, attrs):
        path = paths[-1] if paths else ''
        if attrs.get('name'):
            path = (path + '.' + attrs['name']).lstrip('.')
        paths.append(path)
        if path not in states or tag.rsplit('}', 1)[-1] != 'subform':
            return
        offset = parser.CurrentByteIndex
        end = offset
        quote = None
        while end < len(xml_bytes):
            char = xml_bytes[end]
            if quote is not None:
                if char == quote:
                    quote = None
            elif char in (34, 39):
                quote = char
            elif char == 62:
                break
            end += 1
        opening = xml_bytes[offset:end + 1]
        presence = states[path].encode('ascii')
        pattern = br'(\bpresence\s*=\s*[\x22\x27])[^\x22\x27]*([\x22\x27])'
        if re.search(pattern, opening):
            opening = re.sub(pattern, lambda m: m[1] + presence + m[2], opening, count=1)
        else:
            insert = len(opening) - (2 if opening.endswith(b'/>') else 1)
            opening = opening[:insert] + b' presence="' + presence + b'"' + opening[insert:]
        edits.append((offset, end + 1, opening))

    parser.StartElementHandler = start
    parser.EndElementHandler = lambda tag: paths.pop()
    parser.Parse(xml_bytes, True)
    for start, end, replacement in reversed(edits):
        xml_bytes = xml_bytes[:start] + replacement + xml_bytes[end:]
    return xml_bytes


WORK = 'form1.Page3.DetailsOfWork.'
OPTIONAL = ('Employer.Name', 'Employer.Addr', 'Location.Prov', 'Location.City',
            'Location.Addr', 'Occupation.Job', 'Occupation.Desc',
            'Duration.FromDate', 'Duration.ToDate')
OPEN = ('OWP', 'PGWP', 'COWP', 'VWOWP')


def work_rules(fields):
    category = fields.get(WORK + 'Purpose.Type')
    if not category:
        return {}
    rules = {WORK + name: ('open', 'disabled' if category in OPEN else 'error')
             for name in OPTIONAL}
    rules[WORK + 'Purpose.Other'] = ('open', 'error') if category == 'Other' else ('readOnly', 'disabled')
    rules[WORK + 'Duration.LMO'] = ('open', 'error') if category in ('LMOS', 'ELMO') else ('readOnly', 'disabled')
    province = fields.get(WORK + 'Location.Prov')
    for name in ('CAQ.CertNum', 'CAQ.CertExpiry'):
        rules[WORK + name] = ('open', 'disabled') if province == '05' else ('readOnly', 'disabled')
    return rules


def work_values(fields):
    result = dict(fields)
    for path, (access, _) in work_rules(fields).items():
        if access == 'readOnly':
            result[path] = ''
    return result


def work_state(raw, fields):
    """Patch only access/required attributes; retain official scripts and PIs."""
    rules = work_rules(fields)
    if not rules:
        return raw
    parser = expat.ParserCreate(namespace_separator='}')
    stack, edits = [], []

    def start(tag, attrs):
        path = stack[-1] if stack else ''
        if attrs.get('name'):
            path = (path + '.' + attrs['name']).lstrip('.')
        stack.append(path)
        kind = tag.rsplit('}', 1)[-1]
        if path not in rules or kind not in ('field', 'validate'):
            return
        offset = parser.CurrentByteIndex
        opening = re.match(br'<(?:[^>"\']|"[^"]*"|\'[^\']*\')*>', raw[offset:])[0]
        name, value = ('access', rules[path][0]) if kind == 'field' else ('nullTest', rules[path][1])
        pattern = rb'\b' + name.encode() + rb'\s*=\s*["\'][^"\']*["\']'
        replacement = name.encode() + b'="' + value.encode() + b'"'
        if re.search(pattern, opening):
            patched = re.sub(pattern, replacement, opening, count=1)
        else:
            pos = len(opening) - (2 if opening.endswith(b'/>') else 1)
            patched = opening[:pos] + b' ' + replacement + opening[pos:]
        edits.append((offset, offset + len(opening), patched))
    parser.StartElementHandler = start
    parser.EndElementHandler = lambda tag: stack.pop()
    parser.Parse(raw, True)
    for begin, end, replacement in reversed(edits):
        raw = raw[:begin] + replacement + raw[end:]
    return raw
