#!/usr/bin/env python3
# sdcard 1.0: a Midnight Commander extfs helper for the images of DivMMC's and DivIDE's SD cards and disks (.mmc,
# .img, .hdf, .vhd): their FAT file systems as folders, read only.
# Copyright (c) 2026 Andrey Bogdanovich/Spectre (Optical Brothers), https://www.zxby.org. MIT License (see LICENSE).
#
# mc runs it as:  sdcard list ARCHIVE
#                 sdcard copyout ARCHIVE NAME FILE
# and for F3 on an image (mc.ext.ini's View):  sdcard info ARCHIVE
#
# An image is raw (the card's sectors from the first one: ZEsarUX's .mmc, a card's dump) or an HDF (RS-IDE: a
# header, then the sectors: Fuse's). Its sector 0 is an MBR with partitions, or a FAT boot sector (a card with no
# partitions). FAT12, FAT16 and FAT32 are read, long names too. One FAT partition is the archive's root; with more,
# each is a folder named by its number in the MBR (1-4, the logical ones from 5). See README.md.

import array
import os
import struct
import sys
import time

SECTOR = 512  # the MBR's sector
FAT_TYPES = {0x01: 'FAT12', 0x04: 'FAT16', 0x06: 'FAT16', 0x0E: 'FAT16', 0x0B: 'FAT32', 0x0C: 'FAT32',
             0x11: 'FAT12', 0x14: 'FAT16', 0x16: 'FAT16', 0x1B: 'FAT32', 0x1C: 'FAT32', 0x1E: 'FAT16'}
EXTENDED = (0x05, 0x0F, 0x85)
PARTITION_NAMES = {0x05: 'extended', 0x0F: 'extended', 0x85: 'extended', 0x07: 'NTFS or exFAT', 0x83: 'Linux',
                   0x82: 'Linux swap', 0xEE: 'GPT', 0xA5: 'FreeBSD', 0xAF: 'HFS+'}
ATTR_LFN = 0x0F
ATTR_VOLUME = 0x08
ATTR_DIR = 0x10


class Error(Exception):
    pass


def u16(b, o):
    return b[o] | b[o + 1] << 8


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


class Image(object):
    """The image's sectors: read at a byte offset from the card's start (past an HDF's header)."""

    def __init__(self, path):
        self.f = open(path, 'rb')
        self.f.seek(0, os.SEEK_END)
        size = self.f.tell()
        self.f.seek(0)
        head = self.f.read(SECTOR)
        self.base, self.kind = 0, 'raw image'
        if head[:7] == b'RS-IDE\x1a':
            if len(head) < 11:
                raise Error('a cut HDF header')
            if head[8] & 1:
                raise Error('an HDF of halved sectors (an 8-bit interface\'s): not supported')
            self.base = u16(head, 9)
            self.kind = 'HDF %d.%d, the sectors from byte %d' % (head[7] >> 4, head[7] & 15, self.base)
        elif head[:8] == b'conectix':
            raise Error('a dynamic VHD: not supported (a fixed VHD is a raw image with a footer: it is read)')
        self.size = size - self.base

    def read(self, offset, n):
        if offset < 0 or offset >= self.size:
            return b''
        self.f.seek(self.base + offset)
        return self.f.read(min(n, self.size - offset))


class Partition(object):
    def __init__(self, number, type_, start, sectors):
        self.number = number  # the MBR's: 1-4, the logical ones from 5; 0: a card with no partitions
        self.type = type_
        self.start = start  # in sectors
        self.sectors = sectors

    def describe(self):
        what = FAT_TYPES.get(self.type) or PARTITION_NAMES.get(self.type, 'not FAT')
        return '%d  %-14s  #%02X  from sector %d, %d sectors (%s)' % (
            self.number, what, self.type, self.start, self.sectors, megabytes(self.sectors * SECTOR))


def megabytes(n):
    return '%.1f MB' % (n / 1048576.0) if n < 10 << 30 else '%.1f GB' % (n / 1073741824.0)


def boot_sector(b):
    """Whether these 512 bytes are a FAT boot sector: a jump, then a BPB that makes sense."""
    if len(b) < 512 or b[0] not in (0xEB, 0xE9) or b[510:512] != b'\x55\xaa' and b[0] != 0xE9:
        return False
    bps, spc, reserved, fats = u16(b, 11), b[13], u16(b, 14), b[16]
    return bps in (512, 1024, 2048, 4096) and spc in (1, 2, 4, 8, 16, 32, 64, 128) and reserved >= 1 and \
        fats in (1, 2)


def partitions(img):
    """The MBR's partitions (the logical ones of an extended partition too), or one partition 0 of the whole image
    when sector 0 is a FAT boot sector."""
    b = img.read(0, SECTOR)
    if len(b) < SECTOR:
        raise Error('an image of %d bytes: shorter than a sector' % len(b))
    if b[3:11] == b'EXFAT   ':
        raise Error('exFAT: not supported (esxDOS and NextZXOS read only FAT)')
    if boot_sector(b):
        return [Partition(0, 0, 0, img.size // SECTOR)]
    if b[:10] == b'PLUSIDEDOS':
        raise Error('a +3e (IDEDOS) disk: its partitions are not FAT')
    if b[510:512] != b'\x55\xaa':
        raise Error('no MBR and no FAT boot sector in sector 0: not a card\'s image')
    out = []
    for i in range(4):
        e = b[446 + 16 * i:462 + 16 * i]
        if e[4] == 0 or e[0] not in (0, 0x80):
            continue
        p = Partition(i + 1, e[4], u32(e, 8), u32(e, 12))
        out.append(p)
        if e[4] == 0xEE:
            raise Error('a GPT disk: not supported (esxDOS reads MBR partitions)')
    for p in list(out):
        if p.type in EXTENDED:
            out += logical(img, p.start)
    return out


def logical(img, base):
    """The logical partitions: a chain of EBRs, each with a partition (from itself) and the next EBR (from base)."""
    out, ebr, seen = [], base, set()
    while ebr not in seen and len(out) < 64:
        seen.add(ebr)
        b = img.read(ebr * SECTOR, SECTOR)
        if len(b) < SECTOR or b[510:512] != b'\x55\xaa':
            break
        e, n = b[446:462], b[462:478]
        if e[4]:
            out.append(Partition(5 + len(out), e[4], ebr + u32(e, 8), u32(e, 12)))
        if n[4] not in EXTENDED:
            break
        ebr = base + u32(n, 8)
    return out


class Entry(object):
    def __init__(self, name, attr, cluster, size, mtime, short):
        self.name = name
        self.attr = attr
        self.cluster = cluster
        self.size = size
        self.mtime = mtime  # (date, time) as FAT keeps them
        self.short = short  # the 8.3 name

    @property
    def dir(self):
        return bool(self.attr & ATTR_DIR)


class FAT(object):
    def __init__(self, img, p):
        self.img = img
        self.offset = p.start * SECTOR
        b = img.read(self.offset, 512)
        if not boot_sector(b):
            raise Error('partition %d: no FAT boot sector' % p.number)
        self.bps, self.spc, reserved, self.fats = u16(b, 11), b[13], u16(b, 14), b[16]
        root_entries, total, fat_sectors = u16(b, 17), u16(b, 19) or u32(b, 32), u16(b, 22) or u32(b, 36)
        self.fat_at = self.offset + reserved * self.bps
        self.fat_size = fat_sectors * self.bps
        self.root_at = self.fat_at + self.fats * self.fat_size
        self.root_size = root_entries * 32
        root_sectors = (self.root_size + self.bps - 1) // self.bps
        self.data_at = self.root_at + root_sectors * self.bps
        self.cluster_size = self.bps * self.spc
        data_sectors = total - reserved - self.fats * fat_sectors - root_sectors
        self.clusters = max(0, data_sectors // self.spc)
        # FAT32 has no 16-bit FAT size (Linux tells it so: mkfs.fat makes small FAT32 of fewer clusters), FAT12 and
        # FAT16 differ by their clusters
        self.bits = 32 if u16(b, 22) == 0 else 12 if self.clusters < 4085 else 16
        self.root_cluster = u32(b, 44) if self.bits == 32 else 0
        ext = 64 if self.bits == 32 else 36  # the extended BPB: the drive, a reserved byte, #29, the serial, the label
        self.label = b[ext + 7:ext + 18].rstrip(b' ').decode('cp866') if b[ext + 2] == 0x29 else ''
        self.oem = b[3:11].rstrip(b' \0').decode('cp866')
        self.total = total
        self.fat_cache = {}

    def fat_sector(self, n):
        if n not in self.fat_cache:
            if len(self.fat_cache) > 4096:
                self.fat_cache.clear()
            self.fat_cache[n] = self.img.read(self.fat_at + n * self.bps, self.bps * 2)  # 12 bits may cross over
        return self.fat_cache[n]

    def next(self, c):
        """The cluster after c in its chain, or None at the chain's end (or a bad, free or impossible one)."""
        at = c * self.bits // 8
        b = self.fat_sector(at // self.bps)
        o = at % self.bps
        if len(b) < o + (4 if self.bits == 32 else 2):
            return None
        if self.bits == 12:
            v = u16(b, o)
            v = v >> 4 if c & 1 else v & 0xFFF
            return v if 2 <= v < 0xFF7 and v < self.clusters + 2 else None
        if self.bits == 16:
            v = u16(b, o)
            return v if 2 <= v < 0xFFF7 and v < self.clusters + 2 else None
        v = u32(b, o) & 0x0FFFFFFF
        return v if 2 <= v < 0x0FFFFFF7 and v < self.clusters + 2 else None

    def chain(self, c):
        """The clusters from c on; a chain that comes back to a cluster ends there."""
        seen = set()
        while c is not None and 2 <= c < self.clusters + 2 and c not in seen:
            yield c
            seen.add(c)
            c = self.next(c)

    def cluster(self, c):
        return self.img.read(self.data_at + (c - 2) * self.cluster_size, self.cluster_size)

    def read_dir(self, cluster):
        """A folder's entries (cluster 0: the root of FAT12/16), long names put together, "." and ".." left out."""
        if cluster == 0 and self.bits != 32:
            data = self.img.read(self.root_at, self.root_size)
        else:
            parts = []
            for c in self.chain(cluster or self.root_cluster):
                parts.append(self.cluster(c))
                if len(parts) * self.cluster_size >= 1 << 21:  # 65536 entries: as far as FAT lets a folder go
                    break
            data = b''.join(parts)
        out, lfn = [], {}
        for i in range(0, len(data) - 31, 32):
            e = data[i:i + 32]
            if e[0] == 0:
                break
            if e[0] == 0xE5:
                lfn = {}
                continue
            if e[11] & 0x3F == ATTR_LFN:
                if e[0] & 0x40:
                    lfn = {}
                lfn[e[0] & 0x3F] = (e[13], e[1:11] + e[14:26] + e[28:32])
                continue
            if e[11] & ATTR_VOLUME:
                lfn = {}
                continue
            short = short_name(e)
            name = long_name(lfn, e[:11]) or short
            lfn = {}
            if short in ('.', '..'):
                continue
            hi = u16(e, 20) if self.bits == 32 else 0
            out.append(Entry(name, e[11], hi << 16 | u16(e, 26), u32(e, 28), (u16(e, 24), u16(e, 22)), short))
        return out

    def volume_label(self):
        """The label from the root's volume entry (Windows and esxDOS keep it there), else the boot sector's."""
        data = self.img.read(self.root_at, self.root_size) if self.bits != 32 else \
            self.cluster(self.root_cluster) if self.root_cluster >= 2 else b''
        for i in range(0, len(data) - 31, 32):
            e = data[i:i + 32]
            if e[0] == 0:
                break
            if e[0] != 0xE5 and e[11] & 0x3F != ATTR_LFN and e[11] & ATTR_VOLUME:
                return e[:11].rstrip(b' ').decode('cp866')
        return self.label

    def free(self):
        """The free clusters, counted in the FAT."""
        data = self.img.read(self.fat_at, self.fat_size)
        n = self.clusters + 2
        if self.bits == 12:
            free = 0
            for c in range(2, min(n, len(data) * 2 // 3)):
                v = u16(data, c * 3 // 2)
                free += (v >> 4 if c & 1 else v & 0xFFF) == 0
            return free
        a = array.array('H' if self.bits == 16 else 'I')
        data = data[:len(data) // a.itemsize * a.itemsize]
        a.frombytes(data)
        if sys.byteorder != 'little':
            a.byteswap()
        a = a[2:n]
        if self.bits == 32:
            return sum(1 for v in a if not v & 0x0FFFFFFF)
        return a.count(0)

    def read_file(self, e, out):
        left = e.size
        if e.cluster < 2:
            return
        for c in self.chain(e.cluster):
            if left <= 0:
                break
            b = self.cluster(c)[:left]
            out.write(b)
            left -= len(b)
            if len(b) < self.cluster_size:
                break


def short_name(e):
    """The 8.3 name: the base, a dot and the extension; lower case where Windows NT's bits 3 and 4 of byte 12 say so
    (it keeps "readme.txt" so, with no long name); #05 at the start is #E5. Bytes from #80 are cp866."""
    raw = bytearray(e[:11])
    if raw[0] == 0x05:
        raw[0] = 0xE5
    base, ext = bytes(raw[:8]).rstrip(b' '), bytes(raw[8:11]).rstrip(b' ')
    base, ext = base.decode('cp866'), ext.decode('cp866')
    if e[12] & 0x08:
        base = base.lower()
    if e[12] & 0x10:
        ext = ext.lower()
    return safe(base + ('.' + ext if ext else ''))


def long_name(parts, short):
    """The long name from its entries (numbered from 1, each with the short name's checksum), or '' if they don't
    make one."""
    if not parts:
        return ''
    s = 0
    for c in short:
        s = ((s >> 1) | (s << 7 & 0x80)) + c & 0xFF
    data = b''
    for i in range(1, len(parts) + 1):
        if i not in parts or parts[i][0] != s:
            return ''
        data += parts[i][1]
    name = data.decode('utf-16-le', 'replace')
    end = name.find('\0')
    return safe(name if end < 0 else name[:end])


def safe(name):
    """A name as mc's listing can hold it: the controls and "/" as %XX."""
    return ''.join('%%%02X' % ord(c) if ord(c) < 0x20 or c in '/\x7f' else c for c in name)


class Card(object):
    """The image's FAT file systems: (folder in the archive, FAT); the folder is '' for the only one."""

    def __init__(self, path):
        self.img = Image(path)
        self.parts = partitions(self.img)
        self.systems, self.notes = [], []
        for p in self.parts:
            if p.number == 0 or p.type in FAT_TYPES:
                try:
                    self.systems.append((p, FAT(self.img, p)))
                except Error as e:
                    self.notes.append(str(e))
        if not self.systems:
            raise Error('no FAT partition on the card' + ''.join('; ' + n for n in self.notes))
        self.folders = [('' if len(self.systems) == 1 else str(p.number), fs) for p, fs in self.systems]

    def walk(self):
        """Every folder and file: (path in the archive, Entry or None for a partition's folder)."""
        for folder, fs in self.folders:
            if folder:
                yield folder, None
            todo, seen = [(folder, 0)], set()
            while todo:
                path, cluster = todo.pop(0)
                if cluster in seen:
                    continue
                seen.add(cluster)
                for e in fs.read_dir(cluster):
                    p = path + '/' + e.name if path else e.name
                    yield p, e
                    if e.dir and e.cluster >= 2:
                        todo.append((p, e.cluster))

    def find(self, stored):
        parts = [p for p in stored.split('/') if p not in ('', '.')]
        if not parts:
            raise Error('%s: not a file' % stored)
        if len(self.folders) == 1:
            fs = self.folders[0][1]
        else:
            fs = dict(self.folders).get(parts[0])
            parts = parts[1:]
            if fs is None or not parts:
                raise Error('%s: no such file on the card' % stored)
        cluster, e = 0, None
        for i, name in enumerate(parts):
            if e is not None and not e.dir:
                raise Error('%s: no such file on the card' % stored)
            found = [x for x in fs.read_dir(cluster) if x.name == name]
            if not found:
                raise Error('%s: no such file on the card' % stored)
            e = found[0]
            cluster = e.cluster
        return fs, e


def mc_date(entry, default):
    d, t = entry.mtime
    year, month, day = (d >> 9) + 1980, d >> 5 & 15, d & 31
    hour, minute = t >> 11, t >> 5 & 63
    if not (1 <= month <= 12 and 1 <= day <= 31 and hour < 24 and minute < 60):
        return default
    return '%02d-%02d-%04d %02d:%02d' % (month, day, year, hour, minute)


def cmd_list(path):
    card = Card(path)
    default = time.strftime('%m-%d-%Y %H:%M', time.localtime(os.stat(path).st_mtime))
    owner = '%d %d' % (os.getuid(), os.getgid())
    lines = []
    for p, e in card.walk():
        if e is None or e.dir:
            lines.append('dr-xr-xr-x 1 %s 0 %s %s' % (owner, default if e is None else mc_date(e, default), p))
        else:
            lines.append('-r--r--r-- 1 %s %d %s %s' % (owner, e.size, mc_date(e, default), p))
    return ''.join(line + '\n' for line in lines)


def cmd_copyout(path, stored, dest):
    fs, e = Card(path).find(stored)
    if e.dir:
        raise Error('%s is a folder' % stored)
    with open(dest, 'wb') as out:
        fs.read_file(e, out)


def info_text(path):
    card = Card(path)
    img = card.img
    lines = ['SD card image, %s: %d bytes (%s)' % (img.kind, img.size, megabytes(img.size))]
    if card.parts[0].number == 0:
        lines.append('No partitions: the file system takes the whole card.')
    else:
        lines += ['', 'Partitions (MBR):']
        lines += ['  ' + p.describe() for p in card.parts]
    for folder, fs in card.folders:
        free = fs.free()
        label = fs.volume_label()
        lines += ['', '%s: FAT%d, label "%s", formatted by "%s"' % (
            'Partition %s' % folder if folder else 'File system', fs.bits, label, fs.oem),
            '  %d-byte sectors, %d-byte clusters, %d clusters (%s), %d free (%s)' % (
                fs.bps, fs.cluster_size, fs.clusters, megabytes(fs.clusters * fs.cluster_size), free,
                megabytes(free * fs.cluster_size))]
        entries = fs.read_dir(0)
        lines += ['', '  %s (the root folder: %d entries)' % (folder + '/' if folder else '/', len(entries))]
        for e in entries:
            name = e.name + ('/' if e.dir else '')
            short = '' if e.short == e.name or e.short.lower() == e.name.lower() else e.short
            size = '' if e.dir else str(e.size)
            lines.append('  %-32s %12s  %s  %s' % (name, size, mc_date(e, '                '), short))
    for n in card.notes:
        lines.append('Note: %s' % n)
    return ('\n'.join(line.rstrip() for line in lines) + '\n').encode('utf-8')


def main(argv):
    args = [os.fsencode(a).decode('utf-8', 'surrogateescape') for a in argv[1:]]
    cmd = args[0] if args else ''
    n = {'list': 2, 'info': 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: sdcard list|info ARCHIVE | copyout ARCHIVE NAME FILE\n')
        return 2
    path = argv[2]
    try:
        if cmd == 'list':
            sys.stdout.buffer.write(cmd_list(path).encode('utf-8', 'surrogateescape'))
        elif cmd == 'info':
            sys.stdout.buffer.write(info_text(path))
        elif cmd == 'copyout':
            cmd_copyout(path, args[2], argv[4])
        else:
            raise Error('the card is only read here')
    except (Error, OSError) as e:
        sys.stderr.write('sdcard: %s\n' % e)
        return 1
    return 0


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