"""IMM0008-only state deltas; preserve official script bytes and occurrence limits."""
from functools import lru_cache
import json
from pathlib import Path
import re
from xml.parsers import expat
from copy import deepcopy
from lxml import etree as E

P = 'form1.Page1.'
CONTACT = P + 'contactInformation.'
PHONES = [CONTACT + p for p in ('q3-4.Phone.', 'q3-4.AltPhone.', 'q5-6.FaxPhone.')]
MANUAL = {'TextField2', 'C1CertificateIssueDate', 'SignatureField1',
          'ApplicationValidatedFlag', 'ApplicationValidatedFlagMirror',
          'DateLastValidated', 'globValidatePressedState'}


@lru_cache(maxsize=1)
def paths():
    result = {}
    for language in ('en', 'fr'):
        path = Path(__file__).with_name(f'imm0008-contract.{language}.json')
        if path.exists():
            for key, meta in json.loads(path.read_text(encoding='utf-8'))['fields'].items():
                result.setdefault(key, set()).add(meta.get('state_path', meta['template_path']))
    return result


def yes(value):
    return str(value).upper() in ('Y', 'YES', '1', 'TRUE')


def no(value):
    return str(value).upper() in ('N', 'NO', '0', 'FALSE')


def normalize_fields(fields):
    result = dict(fields)
    # The official Canada/US branch disables District and rejects stale values.
    for base in ('q1.AddressRow2.', 'q2.ResidentialAddressRow2.'):
        prefix = CONTACT + base
        if result.get(prefix + 'Country.Country') in ('511', '461'):
            result[prefix + 'District'] = ''
    same = result.get(CONTACT + 'q2.SameAsMailingIndicator')
    if yes(same):
        for path in result:
            if path.startswith(CONTACT + 'q2.ResidentialAddress'):
                result[path] = ''
    for prefix in PHONES:
        national = result.get(prefix + 'CanadaUS') == '1'
        international = result.get(prefix + 'Other') == '1'
        for path in result:
            if path.startswith(prefix + ('IntlNumber.' if national else 'NANumber.')) and (national or international):
                result[path] = ''
    for path in (P + 'declaration.FutureContact', P + 'declaration.consentIndicator'):
        result.setdefault(path, '')
    return result


def rules(fields, count):
    """Return exact named widget deltas; counts never change official max."""
    result = {}

    def widget(path, access='open', required=False, presence=None):
        attrs = {'access': access, 'nullTest': 'error' if required else 'disabled'}
        if presence is not None:
            attrs['presence'] = presence
        for target in paths().get(path, {path}):
            result.setdefault(target, {}).update(attrs)

    # Client-confirmed business values can inhabit widgets initially disabled by
    # click-only scripts. Exclude computed telephone comparison fields.
    for path, value in fields.items():
        if value and not path.endswith('.ActualNumber') and not path.startswith(tuple(PHONES)):
            for target in paths().get(path, {path}):
                result[target] = {'access': 'open'}

    result[P + 'depInfoHeader'] = {'presence': 'visible' if count else 'hidden'}
    result[P + 'depPrime'] = {'presence': 'visible' if count else 'hidden'}
    result[P + 'depHeader'] = {'presence': 'visible' if count > 1 else 'hidden'}
    result[P + 'dependants'] = {'presence': 'visible' if count > 1 else 'hidden'}
    for index in range(4):
        result[P + f'dependants[{index}]'] = {'presence': 'visible' if count > index + 1 else 'hidden'}
    result[P + 'BC1'] = result[P + 'BC2'] = {'presence': 'hidden'}
    result['form1.barcodeValues'] = {'presence': 'hidden'}

    for prefix in PHONES:
        national = fields.get(prefix + 'CanadaUS') == '1'
        international = fields.get(prefix + 'Other') == '1'
        active = national or international
        widget(prefix + 'CanadaUS', required=national)
        widget(prefix + 'Other', required=international)
        result[prefix + 'NANumber'] = {'presence': 'visible' if national else 'invisible'}
        result[prefix + 'IntlNumber'] = {'presence': 'invisible' if national else 'visible'}
        widget(prefix + 'NumberCountry', 'readOnly' if national else 'open', international)
        widget(prefix + 'Type', required=active)
        for name in ('AreaCode', 'FirstThree', 'LastFive'):
            widget(prefix + 'NANumber.' + name, required=national)
        widget(prefix + 'IntlNumber.IntlNumber', required=international)

    same = yes(fields.get(CONTACT + 'q2.SameAsMailingIndicator'))
    for path in paths():
        if path.startswith(CONTACT + 'q2.ResidentialAddress') and same:
            widget(path, 'readOnly')
    for base, residential in [('q1.AddressRow2.', False), ('q2.ResidentialAddressRow2.', True)]:
        country = fields.get(CONTACT + base + 'Country.Country')
        if not country or residential and same:
            continue
        north_america = country in ('511', '461')
        widget(CONTACT + base + 'ProvinceState.ProvinceState', 'open' if north_america else 'readOnly', north_america)
        widget(CONTACT + base + 'PostalCode.PostalCode', required=north_america)
        widget(CONTACT + base + 'District', 'readOnly' if north_america else 'open')

    csq = P + 'genDetails.q7.'
    quebec = fields.get(P + 'genDetails.q6.Prov') == '05'
    economic = fields.get(P + 'genDetails.q1.Program') == '02'
    selected = fields.get(csq + 'CSQInd')
    widget(csq + 'CSQInd', 'open' if quebec else 'readOnly', quebec)
    widget(csq + 'CSQNum', 'open' if quebec and yes(selected) else 'readOnly', quebec and yes(selected))
    widget(csq + 'CSQDate', 'open' if quebec and economic and no(selected) else 'readOnly', quebec and economic and no(selected))
    return result


def prepare_phone_state(form, template, fields):
    """Persist Reader-style phone validators and captions, not value deltas.

    The official blank omits validate children on several saved fields. Attribute
    edits alone cannot create their required state. Use the language's template
    captions and preserve its scripts; never add validation-bypass handlers.
    """
    from imm0008_pdf import FORM_NS, local, resolve, template_chain

    def saved_copy(source):
        # Retagging a deepcopy retains its template default namespace; lxml
        # can serialize that as a template caption inside the form packet.
        copy = E.Element('{' + FORM_NS + '}' + local(source), dict(source.attrib))
        copy.text, copy.tail = source.text, source.tail
        for child in source:
            copy.append(saved_copy(child) if isinstance(child.tag, str) else deepcopy(child))
        return copy

    state = rules(fields, 0)
    for prefix in PHONES:
        for leaf in ('CanadaUS', 'Other', 'Type', 'NumberCountry',
                     'NANumber.AreaCode', 'NANumber.FirstThree',
                     'NANumber.LastFive', 'IntlNumber.IntlNumber'):
            path = prefix + leaf
            if path not in paths():
                continue
            chain = template_chain(template, path)
            node = resolve(form, path, named=True, create=True,
                           kinds=[local(n) for n in chain])
            for child in list(node):
                if local(child) == 'value' and leaf not in ('CanadaUS', 'Other'):
                    node.remove(child)
            validate = next((c for c in node if local(c) == 'validate'), None)
            if validate is None:
                validate = E.SubElement(node, '{' + FORM_NS + '}validate')
            required = state[path]['nullTest']
            validate.set('nullTest', required)
            caption = next((c for c in node if local(c) == 'caption'), None)
            if caption is None:
                original = next((c for c in chain[-1] if local(c) == 'caption'), None)
                if original is not None:
                    caption = saved_copy(original)
                    node.append(caption)
            if caption is not None:
                for text in caption.xpath('./*[local-name()="value"]/*[local-name()="text"]'):
                    label = (text.text or '').lstrip('*')
                    text.text = ('*' if required == 'error' else '') + label


def apply_state(raw, fields, count):
    if type(count) is not int or not 0 <= count <= 5:
        raise ValueError('IMM0008 dependent count must be between 0 and 5')
    changes = rules(fields, count)
    parser = expat.ParserCreate(namespace_separator='}')
    stack, children, kinds, edits, clear_values = [], [], [], [], []

    def start(tag, attrs):
        kind = tag.rsplit('}', 1)[-1]
        parent = stack[-1] if stack else ''
        parent_kind = kinds[-1] if kinds else None
        name = attrs.get('name') if kind in ('subform', 'field', 'exclGroup') else None
        path = parent
        if name:
            number = children[-1].get(name, 0) if children else 0
            if children:
                children[-1][name] = number + 1
            suffix = f'[{number}]' if name == 'dependants' else ''
            path = (parent + '.' + name + suffix).lstrip('.')
        stack.append(path)
        children.append({})
        kinds.append(kind)
        # Only a widget's direct value is entered data. Caption/value is a label.
        if kind == 'value' and parent_kind in ('field', 'exclGroup') and parent.rsplit('.', 1)[-1] in MANUAL:
            clear_values.append([parser.CurrentByteIndex, None])
        if kind not in ('subform', 'field', 'exclGroup', 'validate'):
            return
        rule = changes.get(path)
        if not rule:
            return
        applicable = {key: value for key, value in rule.items()
                      if (kind == 'validate') == (key == 'nullTest')}
        if not applicable:
            return
        begin = parser.CurrentByteIndex
        end, quote = begin, None
        while end < len(raw):
            ch = raw[end]
            if quote:
                if ch == quote:
                    quote = None
            elif ch in (34, 39):
                quote = ch
            elif ch == 62:
                break
            end += 1
        opening = raw[begin:end + 1]
        for key, value in applicable.items():
            pattern = rb'(\b' + key.encode('ascii') + rb'\s*=\s*[\x22\x27])[^\x22\x27]*([\x22\x27])'
            encoded = value.encode('ascii')
            if re.search(pattern, opening):
                opening = re.sub(pattern, lambda m: m[1] + encoded + m[2], opening, count=1)
            else:
                insert = len(opening) - (2 if opening.endswith(b'/>') else 1)
                opening = opening[:insert] + b' ' + key.encode('ascii') + b'="' + encoded + b'"' + opening[insert:]
        edits.append((begin, end + 1, opening))

    def end(tag):
        if tag.rsplit('}', 1)[-1] == 'value' and clear_values and clear_values[-1][1] is None:
            offset = parser.CurrentByteIndex
            clear_values[-1][1] = raw.find(b'>', offset) + 1 if raw[offset:offset + 2] == b'</' else offset
        stack.pop()
        children.pop()
        kinds.pop()

    parser.StartElementHandler = start
    parser.EndElementHandler = end
    parser.Parse(raw, True)
    edits.extend((begin, finish, b'') for begin, finish in clear_values if finish is not None)
    for begin, finish, value in sorted(edits, reverse=True):
        raw = raw[:begin] + value + raw[finish:]
    return raw
