import bpy, json, math
from mathutils import Vector
from bpy_extras.object_utils import world_to_camera_view

SEED, GENS = 20261025, 36
PITCH, SECTION, CELL = 0.6, 0.08, 0.24
BASE = SECTION / 2
FPS, LAST = 24, 120
RESOLUTION = (576, 1024)
PASSAGE = ((-1.1, -4.5, 0.0), (1.1, 4.5, 2.3))
GRADE_SIZE = (18.0, 18.0, 0.12)
DIRS = ((1,0,0), (-1,0,0), (0,1,0), (0,-1,0), (0,0,1), (0,0,-1))
POS = DIRS[::2]
SEEDS = tuple((x,y,0) for x in (-5,-4) for y in (-1,0)) + tuple(
    (x,y,0) for x in (4,5) for y in (0,1))
TIMBER_NAMES = [f'Timber_{g:03d}' for g in range(GENS+1)]
CELL_NAMES = [f'Cells_{g:03d}' for g in range(GENS+1)]
OBJECTS = TIMBER_NAMES + CELL_NAMES + ['Grade','PavilionEye','Key','Fill']
# Static world-axis metre anchors; checked geometry precedes camera/lights.
EXPECTED_DIMENSIONS = {'Timber_000': (10*PITCH+SECTION, 2*PITCH+SECTION, SECTION),
                       'Grade': GRADE_SIZE}

def add(a,b):
    return tuple(a[i]+b[i] for i in range(3))

def permitted(c):
    x,y,z = c
    r = (x/7.2)**2 + (y/5.2)**2
    if not (0 <= z <= 9 and r <= 1 and z <= 9-int(2*r)):
        return False
    if z < 4 and not (3 <= abs(x) <= 5 and abs(y) <= 2+z//2):
        return False
    return not (abs(x*PITCH) < 1.1+SECTION/2 and z*PITCH+BASE < 2.3+SECTION/2)

def roll(c,g):
    x,y,z = c
    v = (SEED ^ (x+16)*73856093 ^ (y+16)*19349663 ^
         (z+1)*83492791 ^ g*2654435761) & 0xffffffff
    v = ((v ^ (v >> 16))*0x7feb352d) & 0xffffffff
    v = ((v ^ (v >> 15))*0x846ca68b) & 0xffffffff
    return (v ^ (v >> 16)) / 4294967296.0

def simulate():
    births = {c: 0 for c in SEEDS}
    counts = {c: -1 for c in SEEDS}
    assert all(permitted(c) for c in births)
    for g in range(1, GENS+1):
        old = set(births)
        frontier = {add(c,d) for c in old for d in DIRS} - old
        batch = []
        for c in sorted(frontier):
            if not permitted(c):
                continue
            n = sum(add(c,d) in old for d in DIRS)
            x,y,z = c
            leader = y == 0 and (z == 5 or (x in (-4,4) and z <= 5))
            p = 0.24 + 0.12*(n-1) + (0.24 if (x,y,z-1) in old else 0)
            p += 0.14 if z >= 4 else 0
            if leader or (1 <= n <= 3 and roll(c,g) < p):
                batch.append((c,n))
        for c,n in batch:  # Commit only after the entire old-state evaluation.
            births[c], counts[c] = g,n
    nodes = sorted(births, key=lambda c: (births[c], c))
    index = {c:i for i,c in enumerate(nodes)}
    edges = [(index[c],index[add(c,d)],max(births[c],births[add(c,d)]))
             for c in nodes for d in POS if add(c,d) in births]
    return nodes, births, counts, edges

def point(c):
    return (c[0]*PITCH, c[1]*PITCH, BASE+c[2]*PITCH)

def member_box(a,b):
    axis = next(i for i in range(3) if a[i] != b[i])
    lo = tuple(min(a[i],b[i])-(SECTION/2 if i != axis else 0) for i in range(3))
    hi = tuple(max(a[i],b[i])+(SECTION/2 if i != axis else 0) for i in range(3))
    return lo,hi

def overlaps(a,b):
    return all(a[0][i] < b[1][i]-1e-8 and a[1][i] > b[0][i]+1e-8 for i in range(3))

def verify(nodes,births,edges):
    graph = [set() for c in nodes]
    err = 0.0
    for i,j,g in edges:
        graph[i].add(j)
        graph[j].add(i)
        a,b = point(nodes[i]),point(nodes[j])
        err = max(err,abs(math.dist(a,b)-PITCH))
        assert g == max(births[nodes[i]],births[nodes[j]])
        assert not overlaps(member_box(a,b),PASSAGE), 'Passage intrusion'
    seen, todo = {0},[0]
    while todo:
        for j in graph[todo.pop()]:
            if j not in seen:
                seen.add(j)
                todo.append(j)
    assert len(seen) == len(nodes), 'Disconnected occupied graph'
    assert any(c[2] == 0 for c in nodes) and err < 1e-8
    for c in nodes:
        if births[c]:
            assert any(add(c,d) in births and births[add(c,d)] < births[c] for d in DIRS)
    return {'connected': True, 'ground_connected': True, 'passage_clear': True,
            'max_edge_error_m': err, 'nodes': len(nodes), 'edges': len(edges)}

DIMENSIONS = {}
FACES = ((0,1,3,2),(4,6,7,5),(0,4,5,1),(2,3,7,6),(0,2,6,4),(1,5,7,3))

def corners(box):
    lo,hi = box
    return [(x,y,z) for x in (lo[0],hi[0]) for y in (lo[1],hi[1]) for z in (lo[2],hi[2])]

def mesh(name,boxes,material):
    vertices, faces = [],[]
    for box in boxes:
        k = len(vertices)
        vertices.extend(corners(box))
        faces.extend(tuple(k+i for i in f) for f in FACES)
    data = bpy.data.meshes.new(name+'Mesh')
    data.from_pydata(vertices,[],faces)
    data.update()
    obj = bpy.data.objects.new(name,data)
    bpy.context.scene.collection.objects.link(obj)
    data.materials.append(material)
    DIMENSIONS[name] = tuple(max(v[i] for v in vertices)-min(v[i] for v in vertices)
                             for i in range(3)) if vertices else (0,0,0)
    return obj

def material(name,color):
    m = bpy.data.materials.new(name)
    m.diffuse_color = (*color,1)
    m.use_nodes = True
    shader = m.node_tree.nodes.get('Principled BSDF')
    shader.inputs['Base Color'].default_value = (*color,1)
    shader.inputs['Roughness'].default_value = 0.72
    return m

def visible_keys(obj,events):
    for frame,visible in events:
        obj.hide_render = obj.hide_viewport = not visible
        for prop in ('hide_render','hide_viewport'):
            obj.keyframe_insert(data_path=prop,frame=frame)
    # Boolean visibility channels are discrete, with no interpolated geometry.

def fit_camera(scene,bounds):
    data = bpy.data.cameras.new('PavilionEye')
    data.lens, data.sensor_width, data.sensor_fit = 40,36,'HORIZONTAL'
    data.clip_start, data.clip_end = 0.05,250
    cam = bpy.data.objects.new('PavilionEye',data)
    scene.collection.objects.link(cam)
    scene.camera = cam
    target = (Vector(bounds[0])+Vector(bounds[1]))/2
    azimuth = Vector((0.55,-1.0,0)).normalized()
    distance = 8.0
    for attempt in range(100):
        cam.location = (target.x+azimuth.x*distance,target.y+azimuth.y*distance,1.6)
        cam.rotation_euler = (target-cam.location).to_track_quat('-Z','Y').to_euler()
        bpy.context.view_layer.update()
        uv = [world_to_camera_view(scene,cam,Vector(p)) for p in corners(bounds)]
        if all(p.z > 0 and 0.07 <= p.x <= 0.93 and 0.07 <= p.y <= 0.93 for p in uv):
            break
        distance *= 1.08
    else:
        raise AssertionError('Camera fit failed')
    assert cam.location.y < bounds[0][1] and abs(cam.location.z-1.6) < 1e-6
    return {'eye_height_m': 1.6, 'distance_m': distance,
            'minimum_frame_margin': min(min(p.x,1-p.x,p.y,1-p.y) for p in uv)}

def light(name,location,energy,size):
    data = bpy.data.lights.new(name,'AREA')
    data.energy, data.shape, data.size = energy,'DISK',size
    obj = bpy.data.objects.new(name,data)
    bpy.context.scene.collection.objects.link(obj)
    obj.location = location
    obj.rotation_euler = (Vector((0,0,2))-obj.location).to_track_quat('-Z','Y').to_euler()

def build():
    s = bpy.context.scene
    assert not s.objects, 'Creation requires an empty scene'
    s.unit_settings.system, s.unit_settings.scale_length = 'METRIC',1.0
    s.unit_settings.length_unit = 'METERS'
    s.frame_start, s.frame_end, s.render.fps = 1,LAST,FPS
    s.render.fps_base = 1.0
    s.render.resolution_x, s.render.resolution_y = RESOLUTION
    s.render.resolution_percentage = 100
    s.render.pixel_aspect_x = s.render.pixel_aspect_y = 1.0
    s.render.engine = 'CYCLES'
    s.cycles.samples, s.cycles.seed = 8,SEED
    s.cycles.use_denoising = True
    nodes,births,counts,edges = simulate()
    checks = verify(nodes,births,edges)
    wood = material('Timber', (0.48,0.23,0.085))
    cells = material('GrowthCells', (0.12,0.48,0.42))
    ground = material('Ground', (0.23,0.28,0.20))
    cb, eb = [[] for g in range(GENS+1)], [[] for g in range(GENS+1)]
    for c in nodes:
        p = point(c)
        cb[births[c]].append((tuple(v-CELL/2 for v in p),tuple(v+CELL/2 for v in p)))
    for i,j,g in edges:
        eb[g].append(member_box(point(nodes[i]),point(nodes[j])))
    frames = []
    for g in range(GENS+1):
        grow, swap = 1+71*g//GENS,81+31*g//GENS
        frames.append([g,grow,swap])
        cell = mesh(CELL_NAMES[g],cb[g],cells)
        timber = mesh(TIMBER_NAMES[g],eb[g],wood)
        visible_keys(cell, ([(1,True)] if g == 0 else [(1,False),(grow,True)]) +
                     [(swap-1,True),(swap,False),(LAST,False)])
        visible_keys(timber,[(1,False),(swap-1,False),(swap,True),(LAST,True)])
    mesh('Grade',[((-9,-9,-0.12),(9,9,0))],ground)
    positions = [point(c) for c in nodes]
    bounds = (tuple(min(p[i] for p in positions)-CELL/2 for i in range(3)),
              tuple(max(p[i] for p in positions)+CELL/2 for i in range(3)))
    checks['camera'] = fit_camera(s,bounds)
    light('Key',(1,-4,10),2200,8)
    light('Fill',(-6,4,7),1400,7)
    world = bpy.data.worlds.new('ParkWorld')
    world.use_nodes = True
    world.node_tree.nodes['Background'].inputs['Color'].default_value = (0.32,0.40,0.5,1)
    world.node_tree.nodes['Background'].inputs['Strength'].default_value = 0.5
    s.world = world
    assert sorted(o.name for o in s.objects) == sorted(OBJECTS) and len(OBJECTS) < 500
    s.frame_set(LAST)
    bpy.context.view_layer.update()
    for name,dims in EXPECTED_DIMENSIONS.items():
        assert all(abs(bpy.data.objects[name].dimensions[i]-dims[i]) < 1e-5 for i in range(3))
    assert all(bpy.data.objects[n].hide_render for n in CELL_NAMES)
    assert all(not bpy.data.objects[n].hide_render for n in TIMBER_NAMES)
    records = {'births': [[*c,births[c],counts[c]] for c in nodes], 'edges': edges,
               'frames': frames, 'inventory': OBJECTS, 'dimensions': DIMENSIONS,
               'checks': checks, 'fit_bounds': bounds,
               'rules': {'seed': SEED,'pitch_m': PITCH,'section_m': SECTION,
                         'generations': GENS,'passage_m': PASSAGE,'seeds': SEEDS}}
    for key,value in records.items():
        s['ca_'+key] = json.dumps(value,separators=(',',':'))
    s['ca_blender_version'] = bpy.app.version_string

if __name__ == '__main__':
    build()
