"""Engine of the Star Conflict offline patcher / mod manager.

It discovers mods (mods/*.py), keeps their settings, makes backups of every game file it
touches, always patches from the BACKUP (the original file), and runs the build scripts in
the right order. No GUI code lives here, so it can be used from the command line too.

A mod is a Python file in mods/ that defines MOD (metadata, target files, tweakables) and
apply(files, values, log). See MODDING_GUIDE.md and mods/_template_mod.py.
"""
import hashlib
import importlib.util
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import time
import traceback
from pathlib import Path

APP_DIR = Path(__file__).resolve().parent
MODS_DIR = APP_DIR / 'mods'
SETTINGS_FILE = APP_DIR / 'settings.json'
BACKUP_DIRNAME = '_SCPatcher_backups'
MANIFEST_NAME = 'manifest.json'

# SHA-256 of the pristine files this toolkit was developed against. A different hash is only
# a WARNING (other game builds may differ), but it is the usual sign that the file was edited
# by hand earlier; Steam > Properties > Installed Files > Verify integrity restores them.
KNOWN_ORIGINALS = {
    'data/gamedata/def/objects/ow_loot_tables.lua':
        '53333490ece2842a9d3d6a40ef351936d6c661bf52641a634f1b349d20b1ee01',
    'data/gamedata/def/objects/openworld_entities.lua':
        'f582dbe5600b2acd3e8232885d05347024af50e9040a55bf99c07151680cec69',
    'data/gamedata/def/ex/junk.lua':
        '4f5ceb70e523aa654cf1fc1b8d438676ce88bc8e46e8c1697be4db4506428c5c',
    'data/gamedata/shared/loot_tables.lua':
        '3359681f1d2574f2d256e27e3f32b1c3ba7fee8d3f020d2d74a2972641df4f75',
    'data/gamedata/shared/quests.lua':
        '9cc1998144d3859bdb70231d0a79cfabbf06e63ac4cdf40551eb8e7e8ddab071',
    'cloud/data/quests.xml':
        '69ae7aa5cba58eea8e6af70a2bf6061dd2e9dfd5e74a5e6b416ef6fe686572ab',
    'data/gamedata/designers_constants.lua':
        '7f7d6e795c9d307aff7870185adbc720c6a76ce73f2b1e261a91c9243f533188',
    'cloud/data/default_platform_config.lua':
        '1b8bb3e878445a8e907ce1063b78ba151783bff9c7d1d511cf95ff2024157273',
}

TYPES = ('int', 'float', 'bool', 'choice', 'text')


class PatchError(Exception):
    pass


# --------------------------------------------------------------------------- mods
class Mod:
    """One loaded mod file, with its metadata validated and normalised."""

    def __init__(self, path, module):
        meta = getattr(module, 'MOD', None)
        if not isinstance(meta, dict):
            raise ValueError('no MOD dictionary')
        if not callable(getattr(module, 'apply', None)):
            raise ValueError('no apply(files, values, log) function')
        self.path, self.module = Path(path), module
        self.id = str(meta.get('id', ''))
        if not re.fullmatch(r'[a-z0-9_]+', self.id):
            raise ValueError('MOD["id"] must be lowercase letters, digits and underscores')
        self.name = str(meta.get('name', self.id))
        self.description = str(meta.get('description', ''))
        self.version = str(meta.get('version', '1.0'))
        self.author = str(meta.get('author', ''))
        self.default_enabled = bool(meta.get('default_enabled', True))
        self.experimental = bool(meta.get('experimental', False))
        self.order = int(meta.get('order', 100))
        files = meta.get('files')
        if not isinstance(files, dict) or not files:
            raise ValueError('MOD["files"] must list at least one target file')
        self.files = {}
        for key, spec in files.items():
            if isinstance(spec, str):
                spec = {'path': spec}
            rel = str(spec['path']).replace('\\', '/').strip('/')
            if '..' in rel.split('/'):
                raise ValueError('file path may not contain ".."')
            stage = spec.get('stage', 'lua')
            if stage not in ('lua', 'xml'):
                raise ValueError('stage must be "lua" (before the build) or "xml" (after it)')
            self.files[key] = (rel, stage)
        self.tweakables = [self._tweakable(t) for t in meta.get('tweakables', [])]

    @staticmethod
    def _tweakable(t):
        t = dict(t)
        if t.get('type') not in TYPES:
            raise ValueError('tweakable %r: type must be one of %s' % (t.get('key'), ', '.join(TYPES)))
        if not t.get('key'):
            raise ValueError('tweakable without a key')
        t.setdefault('label', t['key'])
        t.setdefault('help', '')
        t.setdefault('advanced', False)
        if 'default' not in t:
            raise ValueError('tweakable %r has no default' % t['key'])
        if t['type'] in ('int', 'float'):
            t.setdefault('min', 0)
            t.setdefault('max', 100)
            t.setdefault('step', 1 if t['type'] == 'int' else 0.5)
            off = (t['default'] - t['min']) / t['step']
            if abs(off - round(off)) > 1e-6 or not t['min'] <= t['default'] <= t['max']:
                raise ValueError('tweakable %r: the default must lie on the slider grid '
                                 '(min + n * step) between min and max' % t['key'])
        if t['type'] == 'choice' and not t.get('choices'):
            raise ValueError('choice tweakable %r needs choices' % t['key'])
        return t

    def apply(self, files, values, log):
        return self.module.apply(files, values, log)


def discover_mods(mods_dir=MODS_DIR):
    """Load every mods/*.py (files starting with '_' are ignored). Returns (mods, errors)."""
    mods, errors, seen = [], [], set()
    Path(mods_dir).mkdir(exist_ok=True)
    if str(APP_DIR) not in sys.path:
        sys.path.insert(0, str(APP_DIR))
    for path in sorted(Path(mods_dir).glob('*.py')):
        if path.name.startswith('_'):
            continue
        try:
            spec = importlib.util.spec_from_file_location('scmod_' + path.stem, path)
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)
            mod = Mod(path, module)
            if mod.id in seen:
                raise ValueError('duplicate mod id "%s"' % mod.id)
            seen.add(mod.id)
            mods.append(mod)
        except Exception as e:                      # a broken mod must never stop the others
            errors.append('%s: %s' % (path.name, e))
    mods.sort(key=lambda m: (m.order, m.id))
    return mods, errors


def validate_mod_file(path):
    """Used by 'Import mod': load a file without installing it. Returns (Mod or None, error)."""
    try:
        spec = importlib.util.spec_from_file_location('scmod_check', path)
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        return Mod(path, module), ''
    except Exception as e:
        return None, str(e)


# ----------------------------------------------------------------------- settings
def clamp(t, value):
    """Coerce a stored/typed value to the tweakable's type and range."""
    kind = t['type']
    try:
        if kind == 'bool':
            return bool(value) if not isinstance(value, str) else value.lower() in ('1', 'true', 'yes', 'on')
        if kind == 'int':
            return int(max(t['min'], min(t['max'], int(float(value)))))
        if kind == 'float':
            return float(max(t['min'], min(t['max'], float(value))))
        if kind == 'choice':
            return value if value in t['choices'] else t['default']
        return str(value)
    except (TypeError, ValueError):
        return t['default']


class Settings:
    def __init__(self, path=SETTINGS_FILE):
        self.path = Path(path)
        self.data = {'game_path': '', 'run_bats': True, 'enabled': {}, 'values': {}}
        if self.path.exists():
            try:
                self.data.update(json.loads(self.path.read_text(encoding='utf-8')))
            except (OSError, ValueError):
                pass

    def save(self):
        self.path.write_text(json.dumps(self.data, indent=2), encoding='utf-8')

    def is_enabled(self, mod):
        return bool(self.data['enabled'].get(mod.id, mod.default_enabled))

    def set_enabled(self, mod, flag):
        self.data['enabled'][mod.id] = bool(flag)

    def get(self, mod, t):
        return clamp(t, self.data['values'].get(mod.id, {}).get(t['key'], t['default']))

    def set(self, mod, t, value):
        self.data['values'].setdefault(mod.id, {})[t['key']] = clamp(t, value)

    def values_for(self, mod):
        return {t['key']: self.get(mod, t) for t in mod.tweakables}

    def reset_recommended(self, mods):
        """Recommended = every mod's own defaults (enabled flag and all tweakables)."""
        for mod in mods:
            self.data['enabled'].pop(mod.id, None)
            self.data['values'].pop(mod.id, None)


# ------------------------------------------------------------------------ files
def read_text(path):
    # surrogateescape keeps every byte, so a file we do not fully understand round-trips exactly
    return Path(path).read_bytes().decode('utf-8', errors='surrogateescape')


def write_text(path, text):
    Path(path).parent.mkdir(parents=True, exist_ok=True)
    Path(path).write_bytes(text.encode('utf-8', errors='surrogateescape'))


def sha(data):
    if isinstance(data, (str, Path)):
        data = Path(data).read_bytes()
    return hashlib.sha256(data).hexdigest()


def validate_game(game):
    game = Path(game)
    missing = [p for p in ('data/gamedata', 'cloud/data') if not (game / p).is_dir()]
    if missing:
        raise PatchError('This does not look like the Star Conflict folder (missing: %s). '
                         'Pick the folder that contains "data" and "cloud".' % ', '.join(missing))
    return game


def find_file(game, name, maxdepth=3):
    game = Path(game)
    base = len(game.parts)
    for root, dirs, files in os.walk(game):
        if len(Path(root).parts) - base >= maxdepth:
            dirs[:] = []
        dirs[:] = [d for d in dirs if d != BACKUP_DIRNAME]
        for f in files:
            if f.lower() == name.lower():
                return Path(root) / f
    return None


class FileView:
    """What a mod sees: its own keys mapped onto the shared working copies of the files."""

    def __init__(self, work, mapping):
        self._work, self._map = work, mapping

    def __getitem__(self, key):
        return self._work[self._map[key][0]]

    def __setitem__(self, key, text):
        self._work[self._map[key][0]] = text

    def __contains__(self, key):
        return key in self._map


# --------------------------------------------------------------------- pipeline
def _manifest(backup_dir):
    p = backup_dir / MANIFEST_NAME
    if p.exists():
        try:
            return json.loads(p.read_text(encoding='utf-8'))
        except ValueError:
            pass
    return {'files': {}}


def _save_manifest(backup_dir, manifest):
    backup_dir.mkdir(parents=True, exist_ok=True)
    (backup_dir / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2), encoding='utf-8')


def _port_open(port=27017):
    try:
        with socket.create_connection(('127.0.0.1', port), timeout=1):
            return True
    except OSError:
        return False


def _run_build(game, log, step):
    """Windows only: start MongoDB, run prepare_shared.bat, stop the MongoDB we started."""
    if os.name != 'nt':
        log('Not running on Windows: skipping launch_mongodb.bat / prepare_shared.bat.')
        return False
    mongo_bat = find_file(game, 'launch_mongodb.bat')
    prep_bat = find_file(game, 'prepare_shared.bat')
    if not prep_bat:
        log('WARNING: prepare_shared.bat not found in the game folder; run the build scripts yourself.')
        return False
    started = None
    step('Starting MongoDB')
    if _port_open():
        log('MongoDB is already running; using it.')
    elif mongo_bat:
        log('Starting %s' % mongo_bat.name)
        started = subprocess.Popen(['cmd', '/c', str(mongo_bat)], cwd=str(mongo_bat.parent),
                                   creationflags=subprocess.CREATE_NEW_CONSOLE)
        for _ in range(60):
            if _port_open():
                break
            time.sleep(1)
        else:
            log('WARNING: MongoDB did not open its port in 60 s; continuing anyway.')
        time.sleep(2)
    else:
        log('WARNING: launch_mongodb.bat not found; hoping MongoDB is already running.')
    try:
        step('Running prepare_shared.bat (this can take a minute)')
        r = subprocess.run(['cmd', '/c', str(prep_bat)], cwd=str(prep_bat.parent),
                           creationflags=subprocess.CREATE_NO_WINDOW, timeout=900)
        log('prepare_shared.bat finished (exit code %d).' % r.returncode)
        return r.returncode == 0
    finally:
        if started is not None:
            step('Stopping MongoDB')
            subprocess.run(['taskkill', '/PID', str(started.pid), '/T', '/F'],
                           creationflags=subprocess.CREATE_NO_WINDOW,
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def apply_patches(game, mods, settings, log=print, progress=lambda frac, msg='': None,
                  run_bats=True, dry_run=False):
    """Back up, patch (always from the original), run the build, write the XML. True on success."""
    game = validate_game(game)
    backup_dir = game / BACKUP_DIRNAME
    manifest = _manifest(backup_dir)
    active = [m for m in mods if settings.is_enabled(m)]
    log('Enabled mods: %s' % (', '.join(m.name for m in active) or '(none)'))

    # which files are touched, and in which stage
    stage_of, targets = {}, []
    for m in active:
        for rel, stage in m.files.values():
            if rel not in stage_of:
                targets.append(rel)
            stage_of[rel] = 'xml' if (stage == 'xml' or stage_of.get(rel) == 'xml') else 'lua'
    stale = [r for r in manifest['files'] if r not in stage_of and manifest['files'][r].get('patched')]
    n_steps = len(targets) * 2 + len(stale) + 6
    done = [0]

    def step(msg=''):
        done[0] += 1
        progress(min(done[0] / n_steps, 0.99), msg)
        if msg:
            log(msg)

    # 1. backups --------------------------------------------------------------
    step('Checking and backing up game files')
    for rel in targets:
        src = game / rel
        if not src.is_file():
            raise PatchError('Target file not found: %s' % rel)
        bak = backup_dir / rel
        info = manifest['files'].setdefault(rel, {})
        if not bak.exists():
            if not dry_run:
                bak.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src, bak)
            info.update(orig_sha=sha(src), patched=False)
            log('Backed up original: %s' % rel)
            known = KNOWN_ORIGINALS.get(rel)
            if known and known != info['orig_sha']:
                log('  WARNING: %s differs from the pristine copy this toolkit knows. If you edited it '
                    'by hand before, restore it first (Steam > Properties > Installed Files > Verify '
                    'integrity) and run the patcher again. If you did not, ignore this.' % rel)
        else:
            cur = sha(src)
            if cur not in (info.get('orig_sha'), info.get('patched_sha')):
                log('  WARNING: %s was changed outside the patcher since the last run; '
                    'the stored original backup is used.' % rel)
        step()

    # 2. patch in memory (nothing is written unless every mod succeeds) ---------
    work = {rel: read_text(backup_dir / rel if (backup_dir / rel).exists() else game / rel)
            for rel in targets}
    original = dict(work)
    for m in active:
        values = settings.values_for(m)
        log('Applying: %s' % m.name)
        try:
            m.apply(FileView(work, m.files), values, lambda msg, _n=m.name: log('   ' + str(msg)))
        except Exception as e:
            log(traceback.format_exc())
            raise PatchError('Mod "%s" failed (%s). Nothing was changed in your game.' % (m.name, e))
        step()
    changed = [r for r in targets if work[r] != original[r]]
    log('Files changed by the enabled mods: %d of %d' % (len(changed), len(targets)))
    if dry_run:
        log('Dry run: nothing written.')
        progress(1.0, 'Dry run finished')
        return True

    # 3. write the "lua" stage, restore files no mod touches any more ---------
    step('Writing patched files')
    for rel in targets:
        if stage_of[rel] == 'lua':
            write_text(game / rel, work[rel])
            manifest['files'][rel].update(patched=True, patched_sha=sha(game / rel))
    for rel in stale:
        shutil.copy2(backup_dir / rel, game / rel)
        manifest['files'][rel].update(patched=False, patched_sha=None)
        log('Restored original (no enabled mod uses it): %s' % rel)
        step()
    _save_manifest(backup_dir, manifest)

    # 4. build scripts, then the "xml" stage ------------------------------------
    if run_bats:
        _run_build(game, log, step)
    else:
        log('Build scripts skipped. Run launch_mongodb.bat and prepare_shared.bat yourself, '
            'then re-run the patcher (or copy the XML files by hand).')
    step('Writing patched XML files')
    for rel in targets:
        if stage_of[rel] == 'xml':
            write_text(game / rel, work[rel])
            manifest['files'][rel].update(patched=True, patched_sha=sha(game / rel))
    _save_manifest(backup_dir, manifest)
    progress(1.0, 'Done')
    log('Patching finished. You can start the game now.')
    return True


def restore_originals(game, log=print):
    """Copy every backed-up original back into the game folder."""
    game = validate_game(game)
    backup_dir = game / BACKUP_DIRNAME
    manifest = _manifest(backup_dir)
    n = 0
    for rel, info in manifest['files'].items():
        bak = backup_dir / rel
        if bak.exists():
            shutil.copy2(bak, game / rel)
            info.update(patched=False, patched_sha=None)
            n += 1
            log('Restored: %s' % rel)
    _save_manifest(backup_dir, manifest)
    log('Restored %d file(s). If you restored quests.xml, run prepare_shared.bat if the game misbehaves.' % n)
    return n
