#!/usr/bin/env python3
# tape 1.0: a Midnight Commander extfs helper for ZX Spectrum tape images (.tap, .tzx).
# Copyright (c) 2026 Andrey Bogdanovich/Spectre (Optical Brothers), https://www.zxby.org. MIT License (see LICENSE).
#
# mc runs it as:  tape list ARCHIVE
#                 tape copyout ARCHIVE NAME FILE
#                 tape copyin ARCHIVE NAME FILE
#                 tape rm ARCHIVE NAME
# and for F3 on an image (mc.ext.ini's View):  tape catalog ARCHIVE
#
# The archive's root holds the tape's files named as the ROM names them when it loads: a header with its data block
# is one file ("01 Program boot", "03 Bytes screen"), a data block with no header another ("05 Headerless"); the
# number is the block's in CATALOG. tap/ holds the same files as pieces of TAP (their blocks as they are), hobeta/
# the files with headers in hobeta format (for TR-DOS disks), CATALOG the tape's blocks as text. A TAP can be
# changed; a TZX is only read. See README.md.

import os
import re
import stat
import struct
import sys
import tempfile
import time

TAP_DIR = 'tap'
HOBETA_DIR = 'hobeta'
CATALOG = 'CATALOG'
HOBETA_HEADER = 17
SECTOR = 256
MAX_SECTORS = 255  # a TR-DOS file holds 255 sectors (65280 bytes) at most
MAX_DATA = 0xFFFF - 2  # a TAP block's length is 16 bits, and it holds the flag and the checksum too
NO_LINE = 0x8000  # a program's autostart line 32768 and up: none (SAVE without LINE)
UNUSED = 0x8000  # the second parameter of Bytes and of arrays, as SAVE leaves it

# The header's types as the ROM names them when it loads ("Program: boot").
TYPES = ('Program', 'Number array', 'Character array', 'Bytes')
HEADERLESS = 'Headerless'
LISTED = re.compile(r'(?:(\d+) )?(%s|%s)(?: (.+))?$' % ('|'.join(TYPES), HEADERLESS), re.I)

TZX_SIGNATURE = b'ZXTape!\x1a'
# TZX blocks of information only: a header and its data block may have them between.
INFO_IDS = (0x30, 0x31, 0x32, 0x33, 0x35, 0x5A)
ARCHIVE_INFO = {0: 'Title', 1: 'Publisher', 2: 'Author', 3: 'Year', 4: 'Language', 5: 'Type', 6: 'Price',
                7: 'Loader', 8: 'Origin', 0xFF: 'Comment'}


class Error(Exception):
    pass


def u16(data, offset):
    return data[offset] | data[offset + 1] << 8


def u24(data, offset):
    return data[offset] | data[offset + 1] << 8 | data[offset + 2] << 16


def u32(data, offset):
    return struct.unpack('<I', bytes(data[offset:offset + 4]))[0]


def checksum(data):
    x = 0
    for c in data:
        x ^= c
    return x


def sealed(data):
    """The flag and the data with the checksum after them: a block as the ROM's SAVE writes it."""
    return bytes(data) + bytes([checksum(data)])


class Header(object):
    """A standard header: the 17 bytes after the flag #00."""

    def __init__(self, type_, name, length, p1, p2):
        self.type = type_
        self.name = name  # 10 bytes padded with spaces
        self.length = length
        self.p1 = p1  # Program: the autostart line; Bytes: the start; arrays: the variable's name in the high byte
        self.p2 = p2  # Program: the program's length without the variables

    @classmethod
    def parse(cls, data):
        """A block's bytes (the flag #00, the header, the checksum) -> Header, or None if they are not a header."""
        if len(data) != 19 or data[0] != 0 or data[1] > 3:
            return None
        return cls(data[1], bytes(data[2:12]), *struct.unpack('<HHH', bytes(data[12:18])))

    def block(self):
        return sealed(bytes([0, self.type]) + self.name + struct.pack('<HHH', self.length, self.p1, self.p2))


class Block(object):
    """A block of the tape. data is the bytes it carries (the flag, the data, the checksum), None for a TZX block of
    no data; id is the TZX block's ID (a TAP's blocks are standard ones, #10); kind, info and notes are what CATALOG
    says of it."""

    def __init__(self, data=None, kind='Standard', info='', id_=0x10):
        self.data = data
        self.kind = kind
        self.info = info
        self.id = id_
        self.notes = []
        self.cut = False  # the image ends inside it: no checksum at its end


class File(object):
    """A file of the tape: a header and its data block, a header no data block follows, or a data block with no
    header. blocks are the indices of its blocks in the tape, data the data block's index or None."""

    def __init__(self, blocks, header=None, data=None):
        self.blocks = blocks
        self.header = header
        self.data = data

    @property
    def number(self):
        return self.blocks[0] + 1


def find_files(blocks):
    files, i = [], 0
    while i < len(blocks):
        b = blocks[i]
        if b.data is None:
            i += 1
            continue
        h = Header.parse(b.data)
        if h is None:
            files.append(File([i], data=i))
            i += 1
            continue
        j = i + 1
        while j < len(blocks) and blocks[j].data is None and blocks[j].id in INFO_IDS:
            j += 1
        if j < len(blocks) and blocks[j].data is not None and Header.parse(blocks[j].data) is None:
            files.append(File([i, j], h, j))
            i = j + 1
        else:
            files.append(File([i], h))
            i += 1
    return files


class Tape(object):
    def __init__(self, kind, blocks, damage='', version=None):
        self.kind = kind  # 'TAP' or 'TZX'
        self.blocks = blocks
        self.damage = damage  # what is wrong with the image, '' if nothing
        self.version = version  # a TZX's (major, minor)
        self.files = find_files(blocks)

    def payload(self, f):
        """The file's data: its data block without the flag and the checksum."""
        if f.data is None:
            return b''
        b = self.blocks[f.data]
        return bytes(b.data[1:] if b.cut else b.data[1:-1])

    def flag(self, f):
        d = self.blocks[f.data].data if f.data is not None else b''
        return d[0] if d else 0xFF

    def writable(self):
        if self.kind != 'TAP':
            raise Error('a TZX image is only read here: copy its files out, or into a .tap')
        if self.damage:
            raise Error('the tape is damaged (%s), it is not changed' % self.damage)

    def put(self, at, datas, old=None):
        """Puts blocks (their bytes) in at the block index at, or in place of the file old."""
        if old is not None:
            at = old.blocks[0]
            for i in sorted(old.blocks, reverse=True):
                del self.blocks[i]
        self.blocks[at:at] = [Block(d) for d in datas]
        self.files = find_files(self.blocks)

    def remove(self, f):
        for i in sorted(f.blocks, reverse=True):
            del self.blocks[i]
        self.files = find_files(self.blocks)

    def bytes(self):
        return b''.join(struct.pack('<H', len(b.data)) + b.data for b in self.blocks)


def read_tap(data):
    """A TAP: blocks one after another, each its 16-bit length and its bytes. A cut one is read as far as it goes."""
    blocks, pos, damage = [], 0, ''
    while pos < len(data):
        if pos + 2 > len(data):
            damage = 'a byte past the last block'
            break
        n = u16(data, pos)
        b = Block(bytes(data[pos + 2:pos + 2 + n]))
        blocks.append(b)
        if len(b.data) < n:
            damage = 'the image ends inside block %d: %d bytes of %d' % (len(blocks), len(b.data), n)
            b.notes.append('cut: %d bytes of %d' % (len(b.data), n))
            b.cut = True
            break
        pos += 2 + n
    return Tape('TAP', blocks, damage)


def read_tzx(data):
    if len(data) < 10 or data[:8] != TZX_SIGNATURE:
        raise Error('not a TZX image (no "ZXTape!" signature)')
    buf = bytes(data) + bytes(32)  # a cut block's fields read as zeros
    blocks, pos, damage = [], 10, ''
    while pos < len(data):
        id_ = data[pos]
        b, end = tzx_block(data, buf, pos + 1, id_)
        b.id = id_
        blocks.append(b)
        if end > len(data):
            damage = 'the image ends inside block %d (#%02X), %d bytes short' % (len(blocks), id_, end - len(data))
            b.notes.append('cut')
            b.cut = True
            break
        pos = end
    return Tape('TZX', blocks, damage, version=(data[8], data[9]))


def tzx_block(data, buf, p, id_):
    """The TZX block of ID id_ whose body starts at p -> (Block, the block's end). buf is data with zeros after it,
    for reading a cut block's fields."""
    if id_ == 0x10:
        n = u16(buf, p + 2)
        return Block(bytes(data[p + 4:p + 4 + n])), p + 4 + n
    if id_ == 0x11:
        n = u24(buf, p + 15)
        b = Block(bytes(data[p + 18:p + 18 + n]), 'Turbo')
        b.notes.append('pilot %d x %d, sync %d %d, bits %d %d%s, pause %d ms' % (
            u16(buf, p), u16(buf, p + 10), u16(buf, p + 2), u16(buf, p + 4), u16(buf, p + 6), u16(buf, p + 8),
            last_bits(buf[p + 12]), u16(buf, p + 13)))
        return b, p + 18 + n
    if id_ == 0x12:
        return Block(kind='Pure tone', info='%d pulses of %d T' % (u16(buf, p + 2), u16(buf, p))), p + 4
    if id_ == 0x13:
        return Block(kind='Pulses', info='%d pulses' % buf[p]), p + 1 + 2 * buf[p]
    if id_ == 0x14:
        n = u24(buf, p + 7)
        b = Block(bytes(data[p + 10:p + 10 + n]), 'Pure data')
        b.notes.append('bits %d %d%s, pause %d ms' % (u16(buf, p), u16(buf, p + 2), last_bits(buf[p + 4]),
                                                      u16(buf, p + 5)))
        return b, p + 10 + n
    if id_ == 0x15:
        n = u24(buf, p + 5)
        return Block(kind='Direct recording', info='%d bytes, a sample is %d T' % (n, u16(buf, p))), p + 8 + n
    if id_ == 0x18:
        n = u32(buf, p)
        packing = {1: 'RLE', 2: 'Z-RLE'}.get(buf[p + 9], 'compression %d' % buf[p + 9])
        return Block(kind='CSW recording', info='%d bytes, %d Hz, %s' % (n, u24(buf, p + 6), packing)), p + 4 + n
    if id_ == 0x19:
        n = u32(buf, p)
        return Block(kind='Generalized data', info='%d bytes' % n), p + 4 + n
    if id_ == 0x20:
        ms = u16(buf, p)
        return (Block(kind='Pause', info='%d ms' % ms) if ms else Block(kind='Stop the tape')), p + 2
    if id_ == 0x21:
        n = buf[p]
        return Block(kind='Group start', info=quoted(data[p + 1:p + 1 + n])), p + 1 + n
    if id_ == 0x22:
        return Block(kind='Group end'), p
    if id_ == 0x23:
        jump = u16(buf, p)
        return Block(kind='Jump', info='%+d' % (jump - 0x10000 if jump & 0x8000 else jump)), p + 2
    if id_ == 0x24:
        return Block(kind='Loop start', info='%d times' % u16(buf, p)), p + 2
    if id_ == 0x25:
        return Block(kind='Loop end'), p
    if id_ == 0x26:
        n = u16(buf, p)
        return Block(kind='Call sequence', info='%d calls' % n), p + 2 + 2 * n
    if id_ == 0x27:
        return Block(kind='Return'), p
    if id_ == 0x28:
        n = u16(buf, p)
        items, q = [], p + 3
        for _ in range(buf[p + 2]):
            if q + 3 > p + 2 + n:
                break
            items.append(quoted(data[q + 3:q + 3 + buf[q + 2]]))
            q += 3 + buf[q + 2]
        return Block(kind='Select', info=', '.join(items)), p + 2 + n
    if id_ == 0x2A:
        return Block(kind='Stop if 48K'), p + 4
    if id_ == 0x2B:
        return Block(kind='Signal level', info='high' if buf[p + 4] else 'low'), p + 5
    if id_ == 0x30:
        n = buf[p]
        return Block(kind='Text', info=quoted(data[p + 1:p + 1 + n])), p + 1 + n
    if id_ == 0x31:
        n = buf[p + 1]
        return Block(kind='Message', info='%d s: %s' % (buf[p], quoted(data[p + 2:p + 2 + n]))), p + 2 + n
    if id_ == 0x32:  # the strings tell where it ends, not its length (libspectrum, so Fuse, reads it so too)
        lines, q = [], p + 3
        for _ in range(buf[p + 2]):
            what = ARCHIVE_INFO.get(buf[q], '#%02X' % buf[q])
            lines.append('%s: %s' % (what, free_text(data[q + 2:q + 2 + buf[q + 1]])))
            q += 2 + buf[q + 1]
        return Block(kind='Archive info', info='\n'.join(lines)), q
    if id_ == 0x33:
        return Block(kind='Hardware info', info='%d entries' % buf[p]), p + 1 + 3 * buf[p]
    if id_ == 0x34:
        return Block(kind='Emulation info'), p + 8
    if id_ == 0x35:
        n = u32(buf, p + 16)
        return Block(kind='Custom info', info='%s, %d bytes' % (quoted(data[p:p + 16].rstrip(b' \0')), n)), p + 20 + n
    if id_ == 0x40:
        return Block(kind='Snapshot', info='%d bytes' % u24(buf, p + 1)), p + 4 + u24(buf, p + 1)
    if id_ == 0x5A:
        return Block(kind='Glue'), p + 9
    if id_ in (0x16, 0x17):
        return Block(kind='C64 ROM' if id_ == 0x16 else 'C64 turbo'), p + 4 + u32(buf, p)
    # Since TZX 1.10 a block of a new kind starts with its length.
    return Block(kind='Unknown #%02X' % id_, info='%d bytes' % u32(buf, p)), p + 4 + u32(buf, p)


def last_bits(n):
    return '' if n == 8 else ', last byte %d bits' % n


def free_text(b):
    """A TZX text: line breaks are CR; the bytes from #80 are cp866, as the names."""
    out = []
    for c in bytes(b).replace(b'\r\n', b'\r'):
        if c in (0x0A, 0x0D):
            out.append('\n')
        elif c < 0x20 or c == 0x7F or c == 0xFF:
            out.append(' ')
        else:
            out.append(bytes([c]).decode('cp866'))
    return ''.join(out).rstrip()


def quoted(b):
    return '"%s"' % free_text(b)


def open_tape(path):
    with open(path, 'rb') as f:
        data = f.read()
    if data[:8] == TZX_SIGNATURE:
        return read_tzx(data)
    if os.path.splitext(path)[1].lower() == '.tzx':
        if not data:
            raise Error('an empty file: a TZX is only read here; name it .tap to start a tape')
        raise Error('not a TZX image (no "ZXTape!" signature)')
    return read_tap(data)


def save(path, data):
    """Writes the image anew through a temporary file, so a failure leaves the old one whole; in place when the
    image has other hard links (a new file would part them) or the folder takes no new files."""
    path = os.path.realpath(path)
    st = os.stat(path)
    if not os.access(path, os.W_OK):
        raise Error('%s: the image is read-only' % path)
    fd = None
    if st.st_nlink == 1:
        try:
            fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix='.' + os.path.basename(path) + '.')
        except OSError:  # no right to the folder
            pass
    if fd is None:
        with open(path, 'r+b') as f:
            f.write(data)
            f.truncate()
        return
    try:
        with os.fdopen(fd, 'wb') as f:
            f.write(data)
        os.chmod(tmp, stat.S_IMODE(st.st_mode))
        os.replace(tmp, path)
    except BaseException:
        os.unlink(tmp)
        raise


# Names. A tape's name is 10 bytes padded with spaces. The bytes that can't stand in a file name as they are (/, %,
# the controls, #7F, #FF, a leading space) are %XX; #80..#FE are cp866, as trdos shows them. The reverse gives the
# same bytes back.

def show_char(c, first=False):
    if c < 0x20 or c in (0x25, 0x2F, 0x7F, 0xFF) or c == 0x20 and first:
        return '%%%02X' % c
    if c >= 0x80:
        return bytes([c]).decode('cp866')
    return chr(c)


def show_text(b):
    return ''.join(show_char(c) for c in b)


def show_name(name):
    s = name.rstrip(b' ')
    return ''.join(show_char(c, i == 0) for i, c in enumerate(s)) or '%20'


def read_char(ch):
    if ord(ch) < 0x80:
        return ord(ch)
    try:
        return ch.encode('cp866')[0]
    except UnicodeEncodeError:
        return ord('_')


def read_text(s):
    out = bytearray()
    i = 0
    while i < len(s):
        if s[i] == '%' and re.match(r'[0-9A-Fa-f]{2}$', s[i + 1:i + 3]):
            out.append(int(s[i + 1:i + 3], 16))
            i += 3
        else:
            out.append(read_char(s[i]))
            i += 1
    return bytes(out)


def tape_name(s):
    return (read_text(s)[:10] + b' ' * 10)[:10]


def file_name(f, width):
    """The root's name of a file: the block's number, then the type and the name as the ROM shows them."""
    number = '%0*d' % (width, f.number)
    if f.header is None:
        return '%s %s' % (number, HEADERLESS)
    return '%s %s %s' % (number, TYPES[f.header.type], show_name(f.header.name))


def hobeta_name(f):
    return show_name(f.header.name[:8]) + '.$' + 'BDDC'[f.header.type]


def listing(tape):
    """The names of the tape's files in the root, tap/ and hobeta/: three lists of (name, File), in the tape's
    order. hobeta/'s repeated names (the tape's 10 characters cut to 8) get ~N, N the block's number."""
    width = max(2, len(str(len(tape.blocks))))
    root, tap, hob, seen = [], [], [], set()
    for f in tape.files:
        n = file_name(f, width)
        root.append((n, f))
        if all(len(tape.blocks[i].data) <= 0xFFFF for i in tap_blocks(f)):
            tap.append((n + '.tap', f))
        if f.header is not None and f.data is not None and \
                len(trdos_file(f, tape.payload(f))[1]) <= MAX_SECTORS * SECTOR:
            h = hobeta_name(f)
            if h in seen:
                h = '%s~%d' % (h, f.number)
            seen.add(h)
            hob.append((h, f))
    return root, tap, hob


def tap_blocks(f):
    """The blocks of the file that go into TAP: the header and the data block, not the TZX information between."""
    return [f.blocks[0]] + ([f.data] if f.data is not None and f.data != f.blocks[0] else [])


def parse_name(name):
    """mc's name of a new file -> (the header's type or None for a headerless block, the 10-byte name, the number
    after @ or None). "03 Bytes screen@16384", "Program boot@10", "Headerless"; any other name is Bytes of that
    name: "screen.scr"."""
    at = None
    m = re.match(r'(.+)@(0[xX][0-9A-Fa-f]+|#[0-9A-Fa-f]+|\d+)$', name)
    if m:
        name, num = m.group(1), m.group(2)
        at = int(num[1:], 16) if num[0] == '#' else int(num, 0) if num[:2].lower() == '0x' else int(num)
        if at > 0xFFFF:
            raise Error('%s: the number is past 65535' % num)
    m = LISTED.match(name)
    if m and not (m.group(2).lower() == HEADERLESS.lower() and m.group(3)):
        word = m.group(2).lower()
        if word == HEADERLESS.lower():
            return None, None, at
        return [t.lower() for t in TYPES].index(word), tape_name(m.group(3) or ''), at
    return 3, tape_name(name), at


# Hobeta: the TR-DOS view of a file with a header. Program is B (START: the length with the variables, LENGTH: the
# program's; the autostart line in four bytes #80, #AA, LINE after the data, as TR-DOS writes it), Bytes C, the
# arrays D (START: the header's first parameter, the variable's name in its high byte).

def trdos_file(f, payload):
    """A tape's file -> (TR-DOS's 13 bytes: the name, the type, START, LENGTH; the body)."""
    h = f.header
    if h.type == 0:
        tail = b'\x80\xaa' + struct.pack('<H', h.p1) if h.p1 < NO_LINE else b''
        p1, p2, type_, body = len(payload), min(h.p2, len(payload)), 'B', payload + tail
    else:
        p1, p2, type_, body = h.p1, len(payload), 'C' if h.type == 3 else 'D', payload
    return h.name[:8] + type_.encode() + struct.pack('<HH', p1, p2), body


def hobeta(f, payload):
    entry, body = trdos_file(f, payload)
    sectors = (len(body) + SECTOR - 1) // SECTOR
    head = entry + bytes([0, sectors])
    return head + struct.pack('<H', (105 + 257 * sum(head)) & 0xFFFF) + body + bytes(sectors * SECTOR - len(body))


def parse_hobeta(data):
    """A hobeta file -> (Header, the data), or None if the header's checksum is wrong."""
    if len(data) < HOBETA_HEADER or u16(data, 15) != (105 + 257 * sum(data[:15])) & 0xFFFF:
        return None
    h = data[:15]
    # Byte 14 is the sectors (13-14: their length in bytes); some old writers put the sectors in byte 13, so #00NN
    # is NN sectors when there is more than one sector of data, else NN bytes.
    if h[13] == 0:
        sectors = h[14]
    elif h[14] == 0:
        sectors = h[13] if len(data) - HOBETA_HEADER > SECTOR else 1
    else:
        sectors = (h[13] + 256 * h[14] + SECTOR - 1) // SECTOR
    if sectors == 0 and len(data) > HOBETA_HEADER:
        sectors = (len(data) - HOBETA_HEADER + SECTOR - 1) // SECTOR
    body = bytes(data[HOBETA_HEADER:HOBETA_HEADER + sectors * SECTOR])
    name, type_, (p1, p2) = bytes(h[0:8]) + b'  ', h[8], struct.unpack('<HH', bytes(h[9:13]))
    if type_ == ord('B'):
        n = min(p1, len(body))
        line = u16(body, p1 + 2) if len(body) >= p1 + 4 and body[p1:p1 + 2] == b'\x80\xaa' else NO_LINE
        return Header(0, name, n, line, min(p2, n)), body[:n]
    n = p2 if (p2 + SECTOR - 1) // SECTOR == sectors else len(body)  # LENGTH when it fits the sectors, as trdos
    if type_ == ord('D'):
        return Header(2 if (p1 >> 8) & 0xE0 == 0xC0 else 1, name, n, p1, UNUSED), body[:n]
    return Header(3, name, n, p1, UNUSED), body[:n]  # C, and any other type: Bytes


# Putting files on a tape.

def check_size(data):
    if len(data) > MAX_DATA:
        raise Error('%d bytes: a TAP block holds %d at most' % (len(data), MAX_DATA))


def twin_of(tape, header, data):
    """The tape's file with these data and of the same kind (a header of the same type, or none), or None. A copy
    within the tape (mc renames so: a copy, then rm) goes right after it and takes its parameters."""
    for f in tape.files:
        if f.data is not None and tape.payload(f) == data:
            if (f.header is None) == (header is None) and (header is None or f.header.type == header.type):
                return f
    return None


def new_blocks(tape, folder, name, data, old):
    """The blocks (their bytes) to put on the tape for mc's name and the file's bytes, and the file they go right
    after, or None: at the end. old is the file of that name they replace (mc's F4 saves so), or None."""
    h = parse_hobeta(data)
    if h is None and (folder == TAP_DIR or folder == '' and name.lower().endswith('.tap') and not LISTED.match(name)):
        return tap_piece(tape, folder, name, data)
    if h:
        header, payload = h
        twin = twin_of(tape, header, payload)
        m = re.match(r'(.+)\.\$.(~\d+)?$', name)
        if twin is not None and folder == HOBETA_DIR and m:  # renamed in hobeta/: mc's name
            header.name = tape_name(m.group(1))
            if header.name[:8] == twin.header.name[:8]:
                header.name = twin.header.name
        return [header.block(), sealed(b'\xff' + payload)], twin
    check_size(data)
    if old is not None:  # an edited file keeps its header, but for the length
        flag = bytes([tape.flag(old)])
        if old.header is None:
            return [sealed(flag + data)], None
        h = Header(old.header.type, old.header.name, len(data), old.header.p1, old.header.p2)
        if h.type == 0 and len(data) != old.header.length:  # a program: the old split only if as long
            h.p2 = len(data)
        return [h.block(), sealed(flag + data)], None
    type_, tname, at = parse_name(name)
    if type_ is None:
        return [sealed(b'\xff' + data)], twin_of(tape, None, data)
    h = Header(type_, tname, len(data), NO_LINE, UNUSED)
    twin = twin_of(tape, h, data)
    if twin is not None:
        h.p1, h.p2 = twin.header.p1, twin.header.p2
    elif type_ == 0:
        h.p2 = len(data)
    elif type_ == 3:
        h.p1 = 16384 if len(data) == 6912 else 32768  # a screen, or code
    else:
        h.p1 = 0xC100 if type_ == 2 else 0x8100  # a$(), a()
    if at is not None:
        h.p1 = at
    return [h.block(), sealed(b'\xff' + data)], twin


def tap_piece(tape, folder, name, data):
    """A TAP file put on the tape: its blocks as they are. A piece of one file named as the root's files are (in
    tap/: "03 Bytes screen.tap") takes that type and name for its header."""
    piece = read_tap(data)
    if piece.damage:
        raise Error('%s: not a TAP file (%s)' % (name, piece.damage))
    if not piece.blocks:
        raise Error('%s: an empty TAP file' % name)
    datas = [b.data for b in piece.blocks]
    stem = name[:-4] if name.lower().endswith('.tap') else name
    if folder == TAP_DIR and len(piece.files) == 1 and piece.files[0].header is not None:
        m = LISTED.match(stem)
        if m and m.group(2).lower() != HEADERLESS.lower():
            type_, tname, _ = parse_name(stem)
            h = piece.files[0].header
            if (type_, tname) != (h.type, h.name):
                h.type, h.name = type_, tname
                datas[0] = h.block()
    twin = None
    for f in tape.files:
        if [tape.blocks[i].data for i in tap_blocks(f)] == [b.data for b in piece.blocks]:
            twin = f
            break
    return datas, twin


# CATALOG.

def var_name(h):
    c = h.p1 >> 8 & 0x1F
    return (chr(0x60 + c) if 1 <= c <= 26 else '?') + ('$' if h.type == 2 else '')


def header_text(h):
    name = '"%s"' % show_text(h.name.rstrip(b' '))
    if h.type == 0:
        s = 'Program: %s' % name
        if h.p1 < NO_LINE:
            s += '  LINE %d' % h.p1
        s += ', %d bytes' % h.length
        if h.p2 < h.length:
            s += ' (program %d, variables %d)' % (h.p2, h.length - h.p2)
        return s
    if h.type == 3:
        return 'Bytes: %s  CODE %d,%d' % (name, h.p1, h.length)
    return '%s: %s  DATA %s(), %d bytes' % (TYPES[h.type], name, var_name(h), h.length)


def catalog_text(tape):
    tzx = tape.kind == 'TZX'
    if tzx:
        lines = ['TZX image, version %d.%02d: %d blocks, %d files' % (tape.version + (len(tape.blocks),
                                                                                  len(tape.files)))]
    else:
        lines = ['TAP image: %d blocks, %d files' % (len(tape.blocks), len(tape.files))]
    if tape.damage:
        lines.append('Damaged: %s' % tape.damage)
    lines += ['', '   N  %sFlag  Length  Contents' % ('Block             ' if tzx else '')]
    role = {}
    for f in tape.files:
        role[f.blocks[0]] = f
        if f.data is not None:
            role[f.data] = f
    for i, b in enumerate(tape.blocks):
        left = '%4d  ' % (i + 1) + ('%-16s  ' % b.kind if tzx else '')
        notes = []
        if b.data is None:
            left += ' ' * 14
            text = b.info
        else:
            left += '%-4s  %6d  ' % ('#%02X' % b.data[0] if b.data else '', max(0, len(b.data) - 2))
            f = role.get(i)
            if f is not None and f.header is not None and i == f.blocks[0]:
                text = header_text(f.header)
                if f.data is None:
                    notes.append('no data block after it')
            elif f is not None and f.header is not None:
                text = 'data'
                if len(b.data) - 2 != f.header.length:
                    notes.append('the header says %d bytes' % f.header.length)
            else:
                text = 'headerless'
            if len(b.data) >= 2 and checksum(b.data) and not b.cut:
                notes.append('checksum wrong')
        notes += b.notes
        text = '; '.join([text] + notes) if text else '; '.join(notes)
        for j, line in enumerate(text.split('\n') or ['']):
            lines.append((left if j == 0 else ' ' * len(left)) + line)
    return ('\n'.join(line.rstrip() for line in lines) + '\n').encode('utf-8')


# The commands.

def split(stored):
    """mc's path in the archive -> ('', 'tap' or 'hobeta'; the file's name)."""
    parts = [p for p in stored.split('/') if p not in ('', '.')]
    if len(parts) == 2 and parts[0] in (TAP_DIR, HOBETA_DIR):
        return parts[0], parts[1]
    if len(parts) == 1:
        return '', parts[0]
    raise Error('%s: a tape has no folders' % stored)


def lookup(tape, folder, name, moved=True):
    """The file of mc's name, or None. mc doesn't reread the listing after a change, and a deletion moves the next
    files' numbers: with moved, a number that doesn't fit is looked past, the name and the type tell the file when
    no other file has them. Not for a file put in: there a name not on the tape is a new file (from another tape,
    say, where the numbers are other)."""
    root, tap, hob = listing(tape)
    names = {'': root, TAP_DIR: tap, HOBETA_DIR: hob}[folder]
    for n, f in names:
        if n == name:
            return f
    if not moved:
        return None
    if folder == HOBETA_DIR:
        m = re.match(r'(.+?)(?:~(\d+))?$', name)
        key = lambda n: re.sub(r'~\d+$', '', n)
    else:
        m = re.match(r'(\d+) (.+)$()', name)
        key = lambda n: n.split(' ', 1)[1]
    if not m:
        return None
    rest = m.group(2) if folder != HOBETA_DIR else m.group(1)
    same = [f for n, f in names if key(n) == rest]
    if len(same) == 1:
        return same[0]
    number = m.group(1) if folder != HOBETA_DIR else m.group(2)
    for f in same:
        if number and f.number == int(number):
            return f
    return None


def find(tape, stored):
    folder, name = split(stored)
    f = lookup(tape, folder, name)
    if f is None:
        raise Error('%s: no such file on the tape (leave the tape and enter it again to see it afresh)' % stored)
    return folder, f


def cmd_list(path):
    tape = open_tape(path)
    date = time.strftime('%m-%d-%Y %H:%M', time.localtime(os.stat(path).st_mtime))
    owner = '%d %d' % (os.getuid(), os.getgid())
    w, d = ('-rw-r--r--', 'drwxr-xr-x') if tape.kind == 'TAP' else ('-r--r--r--', 'dr-xr-xr-x')
    root, tap, hob = listing(tape)
    lines = ['-r--r--r-- 1 %s %d %s %s' % (owner, len(catalog_text(tape)), date, CATALOG),
             '%s 1 %s 0 %s %s' % (d, owner, date, TAP_DIR),
             '%s 1 %s 0 %s %s' % (d, owner, date, HOBETA_DIR)]
    for n, f in root:
        lines.append('%s 1 %s %d %s %s' % (w, owner, len(tape.payload(f)), date, n))
    for n, f in tap:
        size = sum(2 + len(tape.blocks[i].data) for i in tap_blocks(f))
        lines.append('%s 1 %s %d %s %s/%s' % (w, owner, size, date, TAP_DIR, n))
    for n, f in hob:
        size = HOBETA_HEADER + (len(trdos_file(f, tape.payload(f))[1]) + SECTOR - 1) // SECTOR * SECTOR
        lines.append('%s 1 %s %d %s %s/%s' % (w, owner, size, date, HOBETA_DIR, n))
    return ''.join(line + '\n' for line in lines)


def cmd_copyout(path, stored, dest):
    tape = open_tape(path)
    if split(stored) == ('', CATALOG):
        data = catalog_text(tape)
    else:
        folder, f = find(tape, stored)
        if folder == TAP_DIR:
            data = b''.join(struct.pack('<H', len(tape.blocks[i].data)) + tape.blocks[i].data for i in tap_blocks(f))
        elif folder == HOBETA_DIR:
            data = hobeta(f, tape.payload(f))
        else:
            data = tape.payload(f)
    with open(dest, 'wb') as out:
        out.write(data)


def cmd_copyin(path, stored, src):
    tape = open_tape(path)
    tape.writable()
    folder, name = split(stored)
    if not folder and name in (CATALOG, TAP_DIR, HOBETA_DIR):
        raise Error('%s is not a file of the tape' % name)
    with open(src, 'rb') as f:
        data = f.read()
    old = lookup(tape, folder, name, moved=False)
    datas, twin = new_blocks(tape, folder, name, data, old)
    if old is not None:
        tape.put(0, datas, old)
    else:
        tape.put(twin.blocks[-1] + 1 if twin is not None else len(tape.blocks), datas)
    save(path, tape.bytes())


def cmd_rm(path, stored):
    tape = open_tape(path)
    tape.writable()
    if split(stored) == ('', CATALOG):
        raise Error('%s is not a file of the tape' % CATALOG)
    _, f = find(tape, stored)
    tape.remove(f)
    save(path, tape.bytes())


def main(argv):
    # mc gives the names back as the list wrote them (UTF-8): take argv's bytes as they came.
    args = [os.fsencode(a).decode('utf-8', 'surrogateescape') for a in argv[1:]]
    cmd = args[0] if args else ''
    n = {'list': 2, 'catalog': 2, 'copyout': 4, 'copyin': 4, 'rm': 3, 'mkdir': 3, 'rmdir': 3}
    if cmd not in n or len(args) != n[cmd]:
        sys.stderr.write('usage: tape list|catalog ARCHIVE | copyout|copyin ARCHIVE NAME FILE | rm ARCHIVE NAME\n')
        return 2
    path = argv[2]
    try:
        if cmd == 'list':
            sys.stdout.buffer.write(cmd_list(path).encode('utf-8', 'surrogateescape'))
        elif cmd == 'catalog':
            sys.stdout.buffer.write(catalog_text(open_tape(path)))
        elif cmd == 'copyout':
            cmd_copyout(path, args[2], argv[4])
        elif cmd == 'copyin':
            cmd_copyin(path, args[2], argv[4])
        elif cmd == 'rm':
            cmd_rm(path, args[2])
        else:
            raise Error('a tape has no folders')
    except (Error, OSError) as e:
        sys.stderr.write('tape: %s\n' % e)
        return 1
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
