#!/usr/bin/env python3
#
# DoA SoundSwap - tool to extract/replace the sound files in Doa5LR
#
# version: 1 (20150616)
# author: immi
#
# thx to deiyu (author of kvs2ogg) for documenting the kvs "encryption"


__appname__='DoA SoundSwap'
__version__='v1'

import os,struct, sys
from time import time

from tkinter import *
from tkinter import ttk
from tkinter import filedialog
from tkinter import messagebox

class Error(Exception):
        pass #bla

class File:
        can_handle=False
        def init(fs, ofs, size):
                fs.seek(ofs)
                sig=fs.read(4)
                if sig==b'KOVS': return KOVS(fs, ofs, size)
                elif sig==b'ATSL': return ATSL(fs, ofs, size)
                else:
                        print("?? what's this: %s"%str(sig))
                        return File(ofs, size)
        def __init__(self, ofs, size):
                self.ofs,self.size=ofs,size
                self.mod_data=None
                self.modified=False
        def get_descr(self): return "????"
        def save(self,fs):
                fs.seek(self.ofs)
                return fs.read(self.size)

class ATSL(File):
        def __init__(self,fs, ofs, size):
                File.__init__(self, ofs, size)
                # eh? ignore this for now
                return

                self.unk=struct.unpack_from('<9L',buf,ofs+4)
                print("%x %x %x %x num=%x %x %x %x %x"%self.unk)
                num=self.unk[4]
                for i in range(num):
                        u2=struct.unpack_from('<10L',buf,ofs+40+i*40)
                        print("idx:%x start:%x len:%x %x %x %x %x %x %x %x"%u2)
#                        sig=struct.unpack_from('<4s',buf,u2[1])[0]
#                        if sig==b'KOVS':
#                                kovs=KOVS(sig,buf,ofs+u2[1])


#                print("")
        def get_descr(self): return "ATSL/????"


class KOVS(File):
        can_handle=True
        def __init__(self,fs, ofs,size):
                File.__init__(self, ofs, size)
                self.ogg_size,self.ogg_loop,*self.unk = memoryview(fs.read(28)).cast('i').tolist()
        def get_descr(self): return "KOVS/Ogg"
        def get_data(self, fs):
                fs.seek(self.ofs+32)
                return fs.read(self.ogg_size)
        def save(self, fs):
                if self.mod_data: data=self.mod_data
                else: data=self.get_data(fs)
                header=bytearray( [0]*32)
                struct.pack_into('<4s2L',header,0,b'KOVS',len(data),self.ogg_loop)
                struct.pack_into('<5L',header,12,*self.unk)
                return header + data

        def xor(self, buf): # returns copy
                out=bytearray(buf)
                for i in range(0x100): out[i] = out[i]^i
                return out

        def extract(self, fs): return self.xor( self.get_data(fs) )
        def swap(self, buf):
                self.mod_data=self.xor(buf)
                self.ogg_data=len(buf)
                self.modified=True
        def set_loop(self, val):
                self.ogg_loop=val
                self.modified=True

class L1GFile:
        def __init__(self, path, filename):
                self.modified=False
                self.path,self.filename=path,filename
                fpath=os.path.join(path,filename)
                with open(fpath, 'rb') as fs:
                        buf = fs.read(24)
                        #print("%s"%filename)
                        if buf[:8] != b'_L1G0000':
                                raise Error("no L1G file(%s):%s"%(str(buf[:8]),filename))
                        size,hsize,unk,n=memoryview(buf[8:]).cast('i').tolist()
                        if unk!=0: print("!! %s unk==%#x"%(filename,unk))
                        if hsize!=24+n*4: Error("whoops: hsize?! @%s"%filename)
                        buf = fs.read(hsize-24)
                        offsets=memoryview(buf).cast('i').tolist()
                        end=size
                        self.contents=[]
                        for ofs in reversed(offsets):
                                self.contents.append( File.init(fs, ofs, end-ofs) )
                                end=ofs
                        self.contents.reverse()
                        return
        def extract(self, idx, out_dir, fname):
                fi = self.contents[idx]
                if not fi.can_handle: raise Error("not supported")

                fpath=os.path.join(self.path,self.filename)
                with open(fpath, 'rb') as fs:
                        data=fi.extract(fs)

                outpath=os.path.join(out_dir, "%s.ogg"%fname)
                with open(outpath,'wb') as fs:
                        fs.write(data)
                        print(" .written %s"%outpath)

        def swap(self, idx):
                fi = self.contents[idx]
                if not fi.can_handle: raise Error("not supported")

                path=filedialog.askopenfilename()
                if path==None: return #cancel

                with open(path,'rb') as fs:
                        fi.swap( fs.read() )
                        self.modified=True

        def save(self):
                if not self.modified: return
                fpath=os.path.join(self.path,self.filename)
                with open(fpath, 'rb') as fs:
                        cont=[]
                        for fi in self.contents:
                                data=fi.save(fs)
                                fi.mod_data=None
                                fi.modified=False
                                cont.append(data)
                ofs_tbl=bytearray( [0]*len(self.contents)*4 )
                ofs = 24+len(ofs_tbl)
                for i, fi in enumerate(self.contents):
                        #print("#%d %x size:%x || %x %x"%(i,ofs,len(cont[i]),fi.ofs,fi.size))
                        struct.pack_into('<L',ofs_tbl,i*4,ofs)
                        ofs += len(cont[i])
                cont = b''.join(cont)
                header = bytearray( [0]*24 )
                struct.pack_into("<8s4L",header,0,b'_L1G0000',
                                len(header)+len(ofs_tbl)+len(cont),
                                24+len(ofs_tbl), 0, len(self.contents))
                buf=header + ofs_tbl + cont

                # overwrite original file
                with open(fpath, 'wb') as fs:
                        fs.write(buf)
                        print(".written %s"%fpath)
                self.modified=False


class Data():
        def __init__(self,path):
                if not os.path.exists(os.path.join(path,'game.exe')):
                        raise Error("not doa dir: game.exe missing")
                self.files=[]
                self.files += self.load_dir( path,'DATA' )
                self.files += self.load_dir( path,'DATA2' )
                self.files.sort(key=lambda x: x.filename)

        def load_dir(self,path, subdir):
                fullpath=os.path.join(path,subdir)
                if not os.path.exists(fullpath):
                        raise Error("didn't find subdir: %s"%subdir)
                return [ L1GFile(path,os.path.join(subdir,f))for f in os.listdir(fullpath) ]

###########
# UI stuff

class ValWidget(ttk.Frame):
        def __init__(self,parent,label,val="",width=25):
                ttk.Frame.__init__(self, parent)
                w=ttk.Label(self,text=label)
                w.grid(column=0,row=0,sticky=(W))

                self.val=StringVar()
                w=ttk.Entry(self,textvariable=self.val,width=width)
                w.grid(column=1,row=0,sticky=(W,E),padx=2)
                self.val.set(val)
        def set(self, val): self.val.set(val)
        def get(self): return self.val.get()


class FileSelWidget(ttk.Frame):
        def __init__(self,parent, sel_cb):
                ttk.Frame.__init__(self,parent)
                self.sel_cb=sel_cb
                self.path=StringVar()
                w=ttk.Label(self, text='Doa5 Directory:')
                w.grid(column=0,row=0)
                w=ttk.Label(self,relief=SUNKEN,width=50,textvariable=self.path)
                w.grid(column=1,row=0,padx=4)
                w=ttk.Button(self,text='select',command=self.btn_click)
                w.grid(column=2,row=0,padx=4)
        def set(self, path):
                self.path.set(path)
                self.sel_cb(path)
        def btn_click(self):
                path=filedialog.askdirectory(mustexist=True)
                if path!=None: self.set(path)

class GUI(ttk.Frame):
        def __init__(self):
                ttk.Frame.__init__(self,padding="4 4 4 4")
                self.grid(sticky=(N,W,E,S))

                self.winfo_toplevel().resizable(False,False)
                self.master.title("%s %s"%(__appname__,__version__))

                w=FileSelWidget(self, self.open_file)
                w.grid(column=0,row=0,columnspan=3,sticky=(W,E),padx=2)
                self.fiSelect=w

                self.out_dir=ValWidget(self, 'OutputDir',width=40)
                self.out_dir.grid(column=0,row=1,columnspan=4,sticky=(W,E),pady=2)
                w=Listbox(self,height=25,width=55,exportselection=False)
                w.bind('<<ListboxSelect>>', lambda x: self.lb_click())
                w.grid(column=0,row=2,rowspan=5,sticky=(N,S,W),pady=2)
                self.lb=w

                self.nentries=StringVar()
                w=ttk.Label(self,text="   ",textvariable=self.nentries)
                w.grid(column=0,row=7,sticky=(S,W),pady=2)

                w=Listbox(self,height=20, width=32, exportselection=False)
                w.grid(column=1,row=2,columnspan=3,sticky=(N,W),padx=6,pady=2)
                w.bind('<<ListboxSelect>>', lambda x: self.det_click())
                self.detail=w

                self.loop=StringVar()
                w=ttk.Button(self,text='set loop',command=self.click_loop)
                w.grid(column=1,row=4,sticky=(E))
                self.btn_loop=w
                w=ttk.Entry(self,textvariable=self.loop,width=10)
                w.grid(column=2,row=4)

                self.out_name=StringVar()
                w=ttk.Button(self, text='extract',command=self.click_extract)
                w.grid(column=1,row=5)
                self.btn_extract=w
                w=ttk.Entry(self, justify=RIGHT,textvariable=self.out_name,width=8)
                w.grid(column=2,row=5,sticky=(E))
                w=ttk.Label(self, text='.ogg')
                w.grid(column=3,row=5,sticky=(W))

                w=ttk.Button(self,text='extract all',command=self.click_extractall)
                w.grid(column=1,row=6)
                self.btn_extr_all=w

                w=ttk.Button(self,text='swap',command=self.swap)
                w.grid(column=1,row=7)
                self.btn_swap=w

                w=ttk.Button(self,text='save file', command=self.write)
                w.grid(column=2,row=7,sticky=(S,E))
                self.btn_write=w

                self.data=None
                self.cur_fi=None
                self.lb_click()

        def det_click(self):
                sel = self.detail.curselection()
                if not self.cur_fi or len(sel)==0: return
                kovs=self.cur_fi.contents[ int(sel[0]) ]
                if kovs.get_descr() != "KOVS/Ogg":
                        self.btn_loop['state']='disabled'
                        self.loop.set('')
                else:
                        self.loop.set(str(kovs.ogg_loop))
                        self.btn_loop['state']='enabled'


        def click_loop(self):
                sel = self.detail.curselection()
                if not self.cur_fi or len(sel)==0: return
                kovs=self.cur_fi.contents[ int(sel[0]) ]
                kovs.set_loop( int(self.loop.get()) )
                self.cur_fi.modified=True
                self.refresh_details()
                self.refresh_file_list()

        def click_extract(self):
                sel = self.detail.curselection()
                if not self.cur_fi or len(sel)==0: return
                try:
                        self.cur_fi.extract( int(sel[0]), self.out_dir.get(), self.out_name.get() )
                        messagebox.showinfo('Success','extraction done',parent=self)
                except Exception as e:
                        print(e)
                        messagebox.showerror('Extraction failed', str(e),parent=self)

        def click_extractall(self):
                if not self.cur_fi: return
                for i, fi in enumerate(self.cur_fi.contents):
                        try:
                                self.cur_fi.extract(i, self.out_dir.get(),
                                                "%s%02d"%(self.out_name.get(),i) )
                        except Exception as e:
                                print(e) # just print
        def swap(self):
                sel = self.detail.curselection()
                if not self.cur_fi or len(sel)==0: return

                try:
                        self.cur_fi.swap( int(sel[0]))
                        self.btn_write['state']='enabled'
                        self.refresh_details()
                        self.refresh_file_list()
                except Exception as e:
                        print(e)
                        messagebox.showerror('Swap failed',str(e),parent=self)

        def write(self):
                if not self.cur_fi or not self.cur_fi.modified: return
                try:
                        self.cur_fi.save()
                        messagebox.showinfo('Success', 'save successfull')
                except Exception as e:
                        print(e)
                        messagebox.showerror('Error', 'save failed')
                self.refresh_details()
                self.refresh_file_list()

        def refresh_details(self):
                self.detail.delete(0,END)
                for i,snd in enumerate(self.cur_fi.contents):
                        self.detail.insert(END, "%02d#  %s"%(i,snd.get_descr()))
                        if snd.modified:
                                self.detail.itemconfig(END, foreground='#884400',
                                                selectforeground='#aa8800')
                if  self.cur_fi.modified: self.btn_write['state']='enabled'
                else: self.btn_write['state']='disabled'


        def lb_click(self):
                self.cur_fi=None
                sel = self.lb.curselection()
                if len(sel)==0: return

                self.cur_fi=self.data.files[ int(sel[0]) ]
                self.refresh_details()

        def refresh_file_list(self):
                sel = self.lb.curselection()
                if len(sel)==0: sel=0
                else: sel=int(sel[0])
                self.lb.delete(0,END)

                for fi in self.data.files:
                        self.lb.insert(END, " %4s"%fi.filename)
                        if fi.modified:
                                self.lb.itemconfig(END, foreground='#884400',
                                                selectforeground='#aa8800')
                self.lb.see(sel)

        def open_file(self, path):
                st=time()
                self.data=Data(path)
                print(".load: %.3f"%(time()-st))

                self.refresh_file_list()
                self.nentries.set("%d sound files"%len(self.data.files))
                self.btn_write['state']='disabled'


        def run(self, path=None):
                self.out_dir.set(".")
                self.out_name.set('out')
                if path: self.fiSelect.set(path)
                self.master.mainloop()

def run():
        gui=GUI()
        if len(sys.argv)>1: gui.run(sys.argv[1])
        else: gui.run()

if __name__ == '__main__':
        run()
