#!/usr/bin/env python3
"""hpi.py — Total Annihilation HPI/UFO/CCX parser (TA-compatible, stdlib only).

Lists and extracts HPI v1 archives, including key-encrypted directories.
Supports stored, TA-LZ77 and zlib SQSH chunks.

Usage:
    python hpi.py <archive> <outdir>          extract all files
    python hpi.py <archive> --list            list contents only

MIT licence. Tested against 25 MB of community archives (591/591 files) and
key-encrypted UFOs from the wild. Format notes: https://ta-archive.com/tools/py/
"""
import struct, zlib, sys, os


def decrypt_key(key_field):
    """TAUtil TransformKey."""
    key = 0
    if key_field:
        key = (key_field * 4 | (key_field >> 6)) & 0xFF
        if key >= 128:
            key -= 256
    return key & 0xFF


def xor_decrypt(buf, base_off, key):
    """Directory/chunk decrypt: out[i] = ((base+i) & 0xFF ^ key) ^ buf[i].
    base_off = file-absolute offset of buf[0]. Identity when key==0."""
    if not key:
        return bytes(buf)
    return bytes((((base_off + i) & 0xFF) ^ key) ^ b for i, b in enumerate(buf))


class Hpi:
    """HPI v1 archive. data = full archive bytes."""

    def __init__(self, data):
        self.d = data
        magic, version = struct.unpack('<4sI', data[:8])
        if magic != b'HAPI':
            raise ValueError(f'not an HPI file (magic {magic!r})')
        if version != 0x00010000:
            raise ValueError(f'unsupported HPI version {version:#x}')
        self.dirsize, self.headerkey, self.start = struct.unpack('<3I', data[8:20])
        self.key = decrypt_key(self.headerkey)
        # Decrypted directory buffer spans [0, dirsize); all directory offsets
        # inside are file-absolute.
        self.dir = bytearray(self.dirsize)
        if self.key:
            self.dir[self.start:self.dirsize] = xor_decrypt(
                data[self.start:self.dirsize], self.start, self.key)
        else:
            self.dir[self.start:self.dirsize] = data[self.start:self.dirsize]

    def directory(self):
        """Parse the full directory tree. Returns nested dicts:
        dir -> {name: subtree or {'size','offset','comp'}}"""
        return self._entries(self.start, '')

    def _entries(self, listoff, path):
        numentries, entrylistoff = struct.unpack('<2I', self._read_at(listoff, 8))
        out = {}
        for i in range(numentries):
            off = entrylistoff + i * 9
            nameoff, dataoff, isdir = struct.unpack('<IIB', self._read_at(off, 9))
            end = self.dir.find(0, nameoff, min(nameoff + 80, self.dirsize))
            name = bytes(self.dir[nameoff:end]).decode('latin-1', 'replace')
            full = f'{path}/{name}' if path else name
            if isdir:
                out[full + '/'] = self._entries(dataoff, full)
            else:
                fdoff, fsize, comp = struct.unpack('<iiB', self._read_at(dataoff, 9))
                # FileData = (DataOffset, FileSize, Compression) — offset first!
                out[full] = {'size': fsize, 'offset': fdoff, 'comp': comp}
        return out

    def _read_at(self, off, n):
        return bytes(self.dir[off:off + n])


def extract_file(h, finfo):
    """Extract one file. finfo['offset'] points at a list of int32 per-chunk
    values (each = csize + 19, header included), then the SQSH chunks."""
    d = h.d
    key = h.key
    nchunks = (finfo['size'] + 65535) // 65536
    sizes_raw = d[finfo['offset']:finfo['offset'] + 4 * nchunks]
    if key:
        sizes_raw = xor_decrypt(sizes_raw, finfo['offset'], key)
    sizes = struct.unpack('<%di' % nchunks, sizes_raw)
    foff = finfo['offset'] + 4 * nchunks
    out = bytearray()
    for csize in sizes:
        hdr = bytearray(d[foff:foff + 19])
        if key:
            hdr = bytearray(xor_decrypt(bytes(hdr), foff, key))
        marker, _u1, comp, enc, cs, dsize, cksum = struct.unpack('<I3B3I', bytes(hdr))
        if marker != 0x48535153:  # 'SQSH'
            raise ValueError(f'bad SQSH magic at {foff:#x}: {marker:#x}')
        payload = bytearray(d[foff + 19:foff + 19 + cs])
        if key:
            payload = bytearray(xor_decrypt(bytes(payload), foff + 19, key))
        if sum(payload) != cksum:
            raise ValueError(f'checksum fail at {foff:#x}: {sum(payload)} != {cksum}')
        if enc:
            # inner encryption: (b - i) ^ i
            payload = bytearray((((b - i) & 0xFF) ^ (i & 0xFF)) for i, b in enumerate(payload))
        if comp == 2:
            out += zlib.decompress(bytes(payload))
        elif comp == 1:
            out += lz77_decompress(payload, dsize)
        else:
            out += payload
        foff += 19 + cs
    return bytes(out[:finfo['size']])


def lz77_decompress(payload, dsize):
    """Total Annihilation LZ77: 4096-byte circular window, position starts at 1.
    Tag byte, LSB first: bit 0 = literal, bit 1 = back-reference
    (pos = word >> 4, count = (word & 0xF) + 2, pos == 0 terminates)."""
    window = bytearray(4096)
    wpos = 1
    out = bytearray()
    i = 0
    n = len(payload)
    while i < n and len(out) < dsize:
        ctrl = payload[i]
        i += 1
        for bit in range(8):
            if i >= n:
                break
            if ctrl & (1 << bit):
                packed = struct.unpack('<H', payload[i:i + 2])[0]
                i += 2
                pos = packed >> 4
                cnt = (packed & 0x0F) + 2
                if pos == 0:
                    return bytes(out)
                for _ in range(cnt):
                    b = window[(wpos - pos) % 4096]
                    out.append(b)
                    window[wpos] = b
                    wpos = (wpos + 1) % 4096
            else:
                b = payload[i]
                i += 1
                out.append(b)
                window[wpos] = b
                wpos = (wpos + 1) % 4096
    return bytes(out)


def collect(tree, path=''):
    """Flatten a directory tree into (name, info) pairs."""
    for k, v in tree.items():
        if isinstance(v, dict) and 'size' in v:
            yield (path + k, v)
        elif isinstance(v, dict):
            yield from collect(v, path + k)


def main():
    if len(sys.argv) < 3:
        print(__doc__)
        sys.exit(1)
    src, outdir = sys.argv[1], sys.argv[2]
    list_only = '--list' in sys.argv
    data = open(src, 'rb').read()
    h = Hpi(data)
    files = list(collect(h.directory()))
    if list_only:
        for name, info in files:
            print(f"{info['size']:>10}  {name}")
        return
    os.makedirs(outdir, exist_ok=True)
    ok = fail = 0
    for name, info in files:
        dst = os.path.join(outdir, name.replace('\\', '/').lstrip('/'))
        os.makedirs(os.path.dirname(dst) or '.', exist_ok=True)
        try:
            open(dst, 'wb').write(extract_file(h, info))
            ok += 1
        except Exception as e:
            print(f'FAIL {name}: {e}')
            fail += 1
    print(f'extracted {ok}, failed {fail}')


if __name__ == '__main__':
    main()
