import bpy, numpy as np, math, hashlib, struct, json, sys
from pathlib import Path
ROOT=Path(__file__).resolve().parent
SOURCE=ROOT/"sources"
PROVENANCE={};seams={}
def read_image(path,size,color=False):
 path=Path(path);PROVENANCE[str(path)]=hashlib.sha256(path.read_bytes()).hexdigest()
 im=bpy.data.images.load(str(path),check_existing=False)
 im.colorspace_settings.name='sRGB' if color else 'Non-Color'
 im.scale(size,size);a=np.array(im.pixels[:],dtype=np.float32).reshape(size,size,4)[:,:,:3].copy();bpy.data.images.remove(im);return a

def periodic(a):
 """Moisan periodic-plus-smooth decomposition: remove only nonperiodic low frequencies.
 Then match the outer texels exactly with a narrow cubic correction (no reflected tiles).
 """
 h,w,c=a.shape;b=np.zeros_like(a)
 b[0,:,:]=a[-1,:,:]-a[0,:,:];b[-1,:,:]=-b[0,:,:]
 b[:,0,:]+=a[:,-1,:]-a[:,0,:];b[:,-1,:]-=a[:,-1,:]-a[:,0,:]
 yy,xx=np.meshgrid(np.arange(h),np.arange(w),indexing='ij')
 den=2*np.cos(2*np.pi*xx/w)+2*np.cos(2*np.pi*yy/h)-4;den[0,0]=1
 f=np.fft.fft2(b,axes=(0,1))/den[:,:,None];f[0,0,:]=0
 out=a-np.fft.ifft2(f,axes=(0,1)).real
 for axis in (0,1):
  t=np.swapaxes(out,0,axis);delta=(t[-1]-t[0])*.5
  for i in range(4):
   weight=(1-i/4)**2*(1+2*i/4);t[i]+=delta*weight;t[-1-i]-=delta*weight
 return np.clip(out,0,1).astype(np.float32)

def save_image(path,a,color=False,grey=False):
 path.parent.mkdir(parents=True,exist_ok=True);h,w=a.shape[:2]
 if a.ndim==2:a=np.repeat(a[:,:,None],3,axis=2)
 im=bpy.data.images.new(path.stem,width=w,height=h,alpha=False,float_buffer=False)
 im.colorspace_settings.name='sRGB' if color else 'Non-Color'
 rgba=np.concatenate((a,np.ones((h,w,1),np.float32)),axis=2).astype(np.float32)
 im.pixels.foreach_set(rgba.ravel());im.filepath_raw=str(path);im.file_format='PNG';im.save();bpy.data.images.remove(im)
 # PNG grayscale encoding for the height contract, using standard library only.
 if grey:
  import zlib
  rows=(np.clip(a[:,:,0],0,1)*65535+.5).astype('>u2')[::-1]
  def chunk(tag,data):return struct.pack('>I',len(data))+tag+data+struct.pack('>I',zlib.crc32(tag+data)&0xffffffff)
  raw=b''.join(b'\0'+row.tobytes() for row in rows)
  path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,16,0,0,0,0))+chunk(b'IDAT',zlib.compress(raw,9))+chunk(b'IEND',b''))
 seams[str(path.relative_to(ROOT))]={'edge_max_abs':float(max(abs(a[0]-a[-1]).max(),abs(a[:,0]-a[:,-1]).max())),'size':[w,h]}

NAMES=['Rubble_stone','Rubble_stone_dark','Oak_frame','Oak_frame_grey','Plaster_ochre','Plaster_white','Plaster_pink','Clay_tile','Clay_tile_dark','Dressed_limestone','Wrought_iron','Leaded_glass','Door_paint','Sign_paint']
TILES=[1.75,1.75,1,1,1.5,1.5,1.5,.65,.65,1.6,1,1,1,1]
def build():
 mats=[];shelf={}
 if '--reuse-shelf' in sys.argv and (ROOT/'materials.json').exists():
  shelf=json.loads((ROOT/'materials.json').read_text())['materials']
  for name in NAMES:
   maps={k:read_image(ROOT/p,32,k=='BaseColor') for k,p in shelf[name]['maps'].items()}
   m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');m.diffuse_color=(*map(float,maps['BaseColor'].mean(axis=(0,1))),1);bs.inputs['Base Color'].default_value=m.diffuse_color;bs.inputs['Roughness'].default_value=float(maps['ORM'][:,:,1].mean());bs.inputs['Metallic'].default_value=float(maps['ORM'][:,:,2].mean());mats.append(m)
  return mats,shelf
 for i,name in enumerate(NAMES):
  n=1024 if i<10 else 512
  if i<2:src='castle_wall_varriation'
  elif i in (2,3,12,13):src='rough_wood'
  elif i in (4,5,6):src='rough_plaster_03'
  elif i==10:src='metal_plate_02'
  else:src='worn_rock_natural_01'
  maps={}
  for kind,suffix in [('BaseColor','diff'),('Normal','nor_gl'),('ORM','arm')]:
   path=next(SOURCE.glob(f'{src}_{suffix}_1k.*'))
   a=read_image(path,n,kind=='BaseColor')
   if i in (2,3,12,13):
    a=np.transpose(a,(1,0,2)).copy()
    if kind=='Normal':a=a[:,:,[1,0,2]]
   lum=a.mean(axis=2)
   if kind=='BaseColor':
    if i<2:a=(lum[:,:,None]*.8+.18)*np.array([1.03,1.0,.89]);a*=.74 if i==1 else 1
    elif i in (2,3):a=(lum[:,:,None]*.72+.012)*np.array([.78,.68,.56] if i==2 else [1.03,1.01,.95])
    elif i in (4,5,6):
     tint=[[.72,.51,.27],[.79,.76,.67],[.70,.48,.41]][i-4];a=(.85+.3*(lum-lum.mean()))[:,:,None]*np.array(tint)
    elif i in (7,8):a=(.56+.9*(lum-lum.mean()))[:,:,None]*np.array([.80,.42,.25]);a*=.89 if i==8 else 1
    elif i==9:a=(.36+.85*(lum-lum.mean()))[:,:,None]*np.array([.94,.9,.80])
    elif i==10:a=(.13+.28*lum)[:,:,None]*np.array([.68,.71,.72])
    elif i==11:a=(.24+.08*(lum-lum.mean()))[:,:,None]*np.array([.7,.88,1.0])
    else:a=(.70+.45*(lum-lum.mean()))[:,:,None]*np.array([1.0,.96,.87])
   elif kind=='Normal':
    v=a*2-1;v[:,:,:2]*=.3 if i in (4,5,6,11,12,13) else .65
    a=(v/np.maximum(np.linalg.norm(v,axis=2,keepdims=True),1e-8)+1)/2
   else:
    a[:,:,0]=.78+.22*a[:,:,0];a[:,:,1]=(.72+.23*a[:,:,1]) if i!=11 else .23;a[:,:,2]=.82 if i==10 else 0
   a=periodic(np.clip(a,0,1))
   if kind=='Normal':v=a*2-1;a=(v/np.maximum(np.linalg.norm(v,axis=2,keepdims=True),1e-8)+1)/2
   maps[kind]=a;save_image(ROOT/'materials'/name/f'{kind}.png',a,kind=='BaseColor')
  m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
  avg=maps['BaseColor'].mean(axis=(0,1));m.diffuse_color=(*map(float,avg),1);bs.inputs['Base Color'].default_value=m.diffuse_color;bs.inputs['Roughness'].default_value=float(maps['ORM'][:,:,1].mean());bs.inputs['Metallic'].default_value=float(maps['ORM'][:,:,2].mean());mats.append(m)
  entry={'tile_size_metres':TILES[i],'maps':{k:f'materials/{name}/{k}.png' for k in maps},'resolution':n,'wrap':'REPEAT','normal_convention':'OpenGL +Y','ORM_channels':{'R':'occlusion','G':'roughness','B':'metallic'}}
  if i in (2,3,12,13):entry.update({'grain_axis':'+X / texture U','physical_repeat_metres':[1.0,1.0],'uv_note':'Geometry maps longitudinal distance to U/1m and cross-grain distance to V/1m. Generated beams must choose their local longitudinal axis, not stretch a whole texture per box.'})
  if i in (0,1,4,5,6):
   hp=next(SOURCE.glob(f'{src}_disp_1k.*'));h=read_image(hp,n)[:,:,0];h=periodic(np.repeat(h[:,:,None],3,axis=2))[:,:,0];h=(h-h.min())/max(float(h.max()-h.min()),1e-8)
   save_image(ROOT/'materials'/name/'Height.png',h,grey=True)
   entry.update({'height_map':f'materials/{name}/Height.png','height_encoding':'16-bit grayscale linear','displacement_scale_metres':.065 if i<2 else .0025,'displacement_bias_metres':0,'height_repeat_metres':TILES[i]})
  shelf[name]=entry
 (ROOT/'materials.json').write_text(json.dumps({'schema':'forge.material-shelf.v1','materials':shelf,'runtime_binding':'Exact-name replacement; external images. BaseColor sRGB; Normal/ORM linear. glTF flipY=false; RepeatWrapping; UV0 already contains physical repeat. Set roughness=metalness=1 so ORM controls them. Heights are for generated wall/plaster displacement; do not displace window surrounds.','provenance':{'license':'CC0','provider':'Poly Haven','ids':['castle_wall_varriation','rough_wood','rough_plaster_03','worn_rock_natural_01','metal_plate_02'],'note':'Registered scan sets graded and made periodic; continuous rough timber scan transposed into longitudinal U; normal X/Y transposed together; glass is an opaque tinted surrogate. No AI texture generation.'}},indent=2))
 (ROOT/'texture_evidence.json').write_text(json.dumps({'source_sha256':PROVENANCE,'seams':seams},indent=2))
 return mats,shelf
