-- Minimal ISO9660 parser
-- Provides: ISO.open(path) -> iso object
-- Methods: :close(), :readPVD(), :listRoot(), :readFile(path)

local M = {}

local SECTOR_SIZE = 2048

local function readBytes(f, pos, len)
    f:seek("set", pos)
    return f:read(len)
end

local function u8(s, i)
    return string.byte(s, i)
end

local function u16le(s, i)
    local b1 = string.byte(s, i)
    local b2 = string.byte(s, i+1)
    return b1 + b2 * 256
end

local function u32le(s, i)
    local b1 = string.byte(s, i)
    local b2 = string.byte(s, i+1)
    local b3 = string.byte(s, i+2)
    local b4 = string.byte(s, i+3)
    return b1 + b2*256 + b3*65536 + b4*16777216
end

local function trimVersion(name)
    -- remove ;1 version and trailing dots
    local n = name:gsub(";1$", "")
    n = n:gsub("%.$", "")
    return n
end

local ISO = {}
ISO.__index = ISO

function M.open(path)
    local f, err = io.open(path, "rb")
    if not f then return nil, err end
    local self = setmetatable({ f = f, path = path }, ISO)
    return self
end

function ISO:close()
    if self.f then self.f:close(); self.f = nil end
end

function ISO:readSector(n)
    local pos = n * SECTOR_SIZE
    return readBytes(self.f, pos, SECTOR_SIZE)
end

function ISO:readPVD()
    -- Primary Volume Descriptor at sector 16 (index 16)
    local pvd = self:readSector(16)
    if not pvd or #pvd < SECTOR_SIZE then return nil, "cannot read PVD" end
    local typecode = u8(pvd,1)
    local id = pvd:sub(2,6)
    if typecode ~= 1 or id ~= "CD001" then return nil, "no primary volume descriptor" end

    local volumeIdentifier = pvd:sub(41,72):gsub('%z+$','')
    -- root directory record starts at offset 157 (1-based index)
    local rdOffset = 157
    local rdLen = u8(pvd, rdOffset)
    local extent = u32le(pvd, rdOffset + 2)
    local dataLen = u32le(pvd, rdOffset + 10)

    self.pvd = {
        volumeIdentifier = volumeIdentifier,
        root = { extent = extent, size = dataLen }
    }
    return self.pvd
end

local function parseDirRecords(buffer, bytesToRead)
    local records = {}
    local offset = 1
    local total = bytesToRead or #buffer
    while offset <= total do
        local len = string.byte(buffer, offset)
        if not len or len == 0 then
            -- padding to sector boundary, move to next byte
            offset = offset + 1
        else
            local rec = {}
            rec.len = len
            rec.extAttrLen = string.byte(buffer, offset+1)
            rec.extent = u32le(buffer, offset+2)
            rec.size = u32le(buffer, offset+10)
            rec.recordingDate = buffer:sub(offset+18, offset+24)
            rec.flags = string.byte(buffer, offset+25)
            rec.fileUnitSize = string.byte(buffer, offset+26)
            rec.interleaveGapSize = string.byte(buffer, offset+27)
            rec.volumeSeq = u16le(buffer, offset+29)
            local fiLen = string.byte(buffer, offset+32)
            local idStart = offset + 33
            local idEnd = idStart + fiLen - 1
            local fileId = buffer:sub(idStart, idEnd)
            -- strip trailing ;1 and dots
            rec.fileIdentifier = fileId
            rec.fileName = trimVersion(fileId)
            rec.isDirectory = (rec.flags & 0x02) ~= 0
            table.insert(records, rec)
            offset = offset + len
        end
    end
    return records
end

function ISO:listRoot()
    if not self.pvd then self:readPVD() end
    local root = self.pvd.root
    local extent = root.extent
    local size = root.size
    local pos = extent * SECTOR_SIZE
    local buffer = readBytes(self.f, pos, size)
    local records = parseDirRecords(buffer, size)
    -- filter out '.' and '..' and entries with empty name
    local out = {}
    for _, r in ipairs(records) do
        local name = r.fileName
        if name and name ~= "" and name ~= "\0" and name ~= "\1" and name ~= "." and name ~= ".." then
            table.insert(out, { name = name, isDirectory = r.isDirectory, extent = r.extent, size = r.size })
        end
    end
    return out
end

local function splitPath(path)
    local parts = {}
    for part in path:gmatch('[^/\\]+') do parts[#parts+1]=part end
    return parts
end

function ISO:findEntryByPath(path)
    if not self.pvd then self:readPVD() end
    local parts = splitPath(path)
    local curExtent = self.pvd.root.extent
    local curSize = self.pvd.root.size
    for i, part in ipairs(parts) do
        -- read directory entries for curExtent
        local buffer = readBytes(self.f, curExtent * SECTOR_SIZE, curSize)
        local records = parseDirRecords(buffer, curSize)
        local found = nil
        for _, r in ipairs(records) do
            local name = trimVersion(r.fileIdentifier)
            if name:lower() == part:lower() then
                found = r; break
            end
        end
        if not found then return nil, "path not found: " .. part end
        if i < #parts then
            if not found.isDirectory then return nil, "not a directory: "..part end
            curExtent = found.extent
            curSize = found.size
        else
            return found
        end
    end
    return nil, "empty path"
end

function ISO:readFile(path)
    local rec, err = self:findEntryByPath(path)
    if not rec then return nil, err end
    if rec.isDirectory then return nil, "path is a directory" end
    local pos = rec.extent * SECTOR_SIZE
    return readBytes(self.f, pos, rec.size)
end

-- return module
return M
