# -*- coding: utf-8 -*-
"""Compose skin.milionar.modern from skin.milionar and its overlay (docs/plan/skin-milionar/240-modern.md).

The modern skin is the classic one with another look, so its source holds only what differs:
resources/skin.milionar.modern has the manifest, overriding includes, textures and the recipe `compose.json`:

  includes  files included first in xml/Includes.xml: Kodi keeps the first include, variable, constant and expression
            of a name, so their definitions replace the classic ones without touching them
  textures  {old: {"to": new, "border": "n"}}: every XML reference to `old` (element text or attribute value) names
            `new`; an element that names it gets the border of the new texture (its slices differ from Estuary's);
            "old@colour" swaps only the elements tinted with that colordiffuse
  masks     {texture value: mask}: a rounded diffuse mask for every texture element naming exactly that value
  colors    {name: AARRGGBB}: values in colors/defaults.xml, new names added (the colour themes still override them)
  patches   [{"file", "find", "replace", "count"}]: exact text edits of structural details no include reaches;
            `count` is how many times `find` must occur, so a changed classic skin fails loudly instead of silently

Overlay files replace the classic file of the same path. Pure Python, no Kodi modules: the installer stages with it
and scripts/skin_harness/compose_modern.py builds the development copy.
"""
import json
import os
import re

RECIPE = 'compose.json'
INCLUDES = os.path.join('xml', 'Includes.xml')
COLORS = os.path.join('colors', 'defaults.xml')
MANIFEST = 'addon.xml'


class ComposeError(ValueError):
    pass


def recipe(overlay):
    with open(os.path.join(overlay, RECIPE), encoding='utf-8') as handle:
        return json.load(handle)


def _raise(error):
    raise error


def _walk(folder, skip=()):
    """{relative path: path} of every file; an unreadable folder fails the whole plan."""
    found = {}
    for base, directories, names in os.walk(folder, onerror=_raise):
        directories.sort()
        for name in sorted(names):
            if name == '.DS_Store':
                continue
            relative = os.path.relpath(os.path.join(base, name), folder)
            if relative not in skip:
                found[relative] = os.path.join(base, name)
    return found


def _texture_rules(textures):
    """One regex for all old names: a reference is a whole value, never a part of a longer path.

    A name with "@<colour>" applies only to elements tinted with that colordiffuse (Estuary draws dialog rows with
    the plain white texture tinted dialog_tint); a plain name applies to every reference."""
    if not textures:
        return None, None
    bases = {name.split('@')[0] for name in textures}
    plain = [name for name in textures if '@' not in name]
    names = '|'.join(re.escape(name) for name in sorted(bases, key=len, reverse=True))
    element = re.compile(r'<(\w+)([^<>]*)>(\s*)(' + names + r')(\s*)</\1>')
    attribute = re.compile(r'="(' + '|'.join(re.escape(name) for name in sorted(plain, key=len, reverse=True)) + r')"'
                           ) if plain else None
    return element, attribute


def _swap_textures(text, textures, rules):
    element, attribute = rules

    def tag(match):
        name, attributes, before, old, after = match.groups()
        tint = re.search(r'\scolordiffuse="([^"]*)"', attributes)
        rule = (tint and textures.get('{}@{}'.format(old, tint.group(1)))) or textures.get(old)
        if rule is None:
            return match.group(0)
        if rule.get('border') is not None and 'texture' in name.lower():   # not a <value> of a variable
            attributes = re.sub(r'\sborder="[^"]*"', '', attributes)
            attributes = ' border="{}"'.format(rule['border']) + attributes
        return '<{}{}>{}{}{}</{}>'.format(name, attributes, before, rule['to'], after, name)

    text = element.sub(tag, text)
    if attribute is None:
        return text
    return attribute.sub(lambda match: '="{}"'.format(textures[match.group(1)]['to']), text)


def _masks(text, masks):
    """A texture element naming exactly one of `masks` (and no diffuse of its own) gets that rounded diffuse mask."""
    for value, mask in masks.items():
        text = re.sub(r'<(\w*texture\w*)((?:(?!diffuse=)[^<>])*)>(' + re.escape(value) + r')</\1>',
                      lambda match: '<{}{} diffuse="{}">{}</{}>'.format(match.group(1), match.group(2), mask,
                                                                       match.group(3), match.group(1)), text)
    return text


def _first_includes(text, files):
    lines = ''.join('\t<include file="{}" />\n'.format(name) for name in files)
    composed, count = re.subn(r'(<includes>\s*\n)', lambda match: match.group(1) + lines, text, count=1)
    if not count:
        raise ComposeError('no <includes> root in ' + INCLUDES)
    return composed


def _colors(text, colors):
    """Replace the value of a classic colour; a colour of the modern skin alone is added."""
    for name, value in colors.items():
        text, count = re.subn(r'(<color name="{}">)[0-9A-Fa-f]{{8}}(</color>)'.format(re.escape(name)),
                              lambda match: match.group(1) + value + match.group(2), text)
        if count > 1:
            raise ComposeError('colour {} defined {} times in {}'.format(name, count, COLORS))
        if not count:
            text = text.replace('</colors>', '\t<color name="{}">{}</color>\n</colors>'.format(name, value), 1)
    return text


def _patch(text, relative, patches):
    for patch in patches:
        if patch['file'] != relative.replace(os.sep, '/'):
            continue
        found = text.count(patch['find'])
        if found != patch.get('count', 1):
            raise ComposeError('patch of {} expects {} x "{}", found {}'.format(
                relative, patch.get('count', 1), patch['find'][:60], found))
        text = text.replace(patch['find'], patch['replace'])
    return text


def plan(classic, overlay=None):
    """{relative path: (source path, transform or None)} of the composed skin (of the classic one without `overlay`).

    `transform(bytes) -> bytes` is set for every XML file the recipe touches; files without one copy unchanged."""
    if overlay is None:
        return {relative: (source, None) for relative, source in _walk(classic).items()}
    spec = recipe(overlay)
    textures, colors, patches = spec.get('textures', {}), spec.get('colors', {}), spec.get('patches', [])
    masks = spec.get('masks', {})
    rules = _texture_rules(textures)
    files = _walk(classic)
    files.update(_walk(overlay, skip=(RECIPE,)))
    patched = {patch['file'] for patch in patches}
    missing = patched - {relative.replace(os.sep, '/') for relative in files}
    if missing:
        raise ComposeError('patches name missing files: ' + ', '.join(sorted(missing)))

    def transform_for(relative):
        posix = relative.replace(os.sep, '/')
        if not relative.endswith('.xml') or relative == MANIFEST or posix.startswith('language/'):
            return None
        touches = (posix.startswith('xml/') and (rules[0] is not None or masks)) or relative in (INCLUDES, COLORS) or \
            posix in patched
        if not touches:
            return None

        def transform(data):
            text = data.decode('utf-8')
            if relative == INCLUDES and spec.get('includes'):
                text = _first_includes(text, spec['includes'])
            if relative == COLORS and colors:
                text = _colors(text, colors)
            text = _patch(text, relative, patches)        # against the classic text; replacements name modern textures
            if posix.startswith('xml/') and masks:
                text = _masks(text, masks)
            if posix.startswith('xml/') and rules[0] is not None:
                text = _swap_textures(text, textures, rules)
            return text.encode('utf-8')
        return transform

    return {relative: (source, transform_for(relative)) for relative, source in files.items()}


def read(entry):
    """Bytes of one planned file."""
    source, transform = entry
    with open(source, 'rb') as handle:
        data = handle.read()
    return transform(data) if transform else data


def write(classic, overlay, target, only=None):
    """Write the composed skin into `target` (files of `only`, a predicate on the relative path, when given)."""
    written = []
    for relative, entry in plan(classic, overlay).items():
        if only is not None and not only(relative):
            continue
        destination = os.path.join(target, relative)
        os.makedirs(os.path.dirname(destination), exist_ok=True)
        data = read(entry)
        try:
            with open(destination, 'rb') as handle:
                if handle.read() == data:
                    continue
        except OSError:
            pass
        with open(destination, 'wb') as handle:
            handle.write(data)
        written.append(relative)
    return written
