#!/usr/bin/env python3
"""Read-only IFC -> reproducible A4 evidence, catalogue and lightweight meshes.
Run with ifcopenshell 0.9.0.post1 and scipy. No source files are written.
"""
import argparse
from collections import Counter
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import scipy
import numpy as np
from scipy.spatial import ConvexHull
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.element as element
import ifcopenshell.util.unit as unit

ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = Path('/home/leeai/data/bim-ifc/_압축해제')
SOURCE_NAMES = {'v1': 'FM_ARC_DigitalHub_v1.ifc', 'v2': 'DigitalHub_FM-ARC_v2.ifc', 'wall': 'SimpleWall.ifc'}
DESIGN_NAMES = {'IsExternal', 'ThermalTransmittance', 'Slope'}


def design_field(field):
    group, name = field.rsplit('.', 1)
    return (group.startswith('Pset_') and name in DESIGN_NAMES) or group.startswith('Qto_') or group == 'BaseQuantities'


def value_change(d):
    return d['presentBefore'] and d['presentAfter']


def clean(value):
    if isinstance(value, dict):
        return {k: clean(v) for k, v in value.items() if k != 'id'}
    if isinstance(value, (tuple, list)):
        return [clean(v) for v in value]
    return value


def canonical(value):
    return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',', ':'))


def digest(value):
    return hashlib.sha256(canonical(value).encode()).hexdigest()


def flatten(psets, excluded=()):
    return {s + '.' + k: v for s, group in psets.items() for k, v in group.items() if s + '.' + k not in excluded}


def properties(obj):
    return clean(element.get_psets(obj, should_inherit=True))


def differences(a, b):
    fa, fb = flatten(a), flatten(b)
    # Membership matters: an absent value and a present null are distinct.
    return [{'field': k, 'before': fa.get(k), 'after': fb.get(k),
             'presentBefore': k in fa, 'presentAfter': k in fb,
             'design': design_field(k)}
            for k in sorted(fa.keys() | fb.keys())
            if k not in fa or k not in fb or fa[k] != fb[k]]


def group_summary(p):
    return [{'name': s, 'count': len(v), 'hash': digest(v)[:12]} for s, v in sorted(p.items())]


def load_sources(source_root=SOURCE_ROOT):
    paths = {}
    for label, name in SOURCE_NAMES.items():
        found = sorted(source_root.rglob(name))
        if len(found) != 1:
            raise ValueError(f'{name}: expected one source, got {len(found)}')
        paths[label] = found[0]
    # Keep files alive alongside entities (IfcOpenShell inverse lookups depend on this).
    files = {label: ifcopenshell.open(str(path)) for label, path in paths.items()}
    return paths, files


def analyze(files):
    maps = {label: {x.GlobalId: x for x in files[label].by_type('IfcElement')} for label in ('v1', 'v2')}
    for label, m in maps.items():
        assert len(m) == len(files[label].by_type('IfcElement')), 'Duplicate GlobalId'
    a, b = maps['v1'], maps['v2']
    shared = sorted(a.keys() & b.keys())
    psets = {label: {g: properties(x) for g, x in m.items()} for label, m in maps.items()}
    catalogue, scene_objects, fields = [], [], Counter()
    field_counts, changed_details, wall_transitions = Counter(), Counter(), Counter()
    for g in sorted(a.keys() | b.keys()):
        x, y = a.get(g), b.get(g)
        change = differences(psets['v1'][g], psets['v2'][g]) if x and y else []
        fields.update(d['field'] for d in change)
        values = [d for d in change if value_change(d)]
        meaningful = [d for d in values if d['design']]
        for d in change:
            # §6 counts non-null v1-only values. Preserve explicit null removals separately.
            kind = 'valueChanged' if value_change(d) else 'v2Only' if not d['presentBefore'] else 'v1OnlyNull' if d['before'] is None else 'v1Only'
            field_counts[kind] += 1
        for d in values:
            changed_details[((y or x).is_a(), d['field'], canonical(d['before']), canonical(d['after']))] += 1
            if (y or x).is_a() == 'IfcWall' and d['field'] == 'Pset_WallCommon.ThermalTransmittance':
                wall_transitions[(d['before'], d['after'])] += 1
        status = 'removed' if y is None else 'added' if x is None else 'changed' if values else 'recorded' if change else 'unchanged'
        item = {'globalId': g, 'ifcClass': (y or x).is_a(), 'name': (y or x).Name or '', 'status': status,
                'changedAll': bool(change), 'changedValues': bool(values), 'changedDesign': bool(meaningful)}
        # Compact first-scene data; the larger property inventory is loaded on selection.
        scene_objects.append(dict(item))
        item['psets'] = {v: group_summary(psets[v][g]) for v in ('v1', 'v2') if g in psets[v]}
        item['changeCount'] = len(change)
        item['valueChangeCount'] = len(values)
        item['designChangeCount'] = len(meaningful)
        item['changedGroups'] = dict(Counter(d['field'].rsplit('.', 1)[0] for d in change))
        selected = values + [d for d in change if not value_change(d)][:4]
        item['examples'] = selected
        catalogue.append(item)
    w = files['wall'].by_type('IfcWall')[0]
    q = properties(w)['BaseQuantities']
    lu = unit.calculate_unit_scale(files['wall'], 'LENGTHUNIT')
    au = unit.calculate_unit_scale(files['wall'], 'AREAUNIT')
    vu = unit.calculate_unit_scale(files['wall'], 'VOLUMEUNIT')
    length, height, width = [q[k] * lu for k in ('Length', 'Height', 'Width')]
    gross = length * height * width
    wall = {'globalId': w.GlobalId, 'stepId': w.id(), 'rawQuantities': q,
            'unitDeclarations': [str(u) for u in files['wall'].by_type('IfcUnitAssignment')[0].Units
                                 if getattr(u, 'UnitType', None) in ('LENGTHUNIT', 'AREAUNIT', 'VOLUMEUNIT')],
            'units': {'length': 'mm', 'area': 'm²', 'volume': 'm³'},
            'scales': {'length': lu, 'area': au, 'volume': vu},
            'length': length, 'height': height, 'width': width,
            'grossVolume': gross, 'netVolume': q['NetVolume'] * vu,
            'grossSideArea': length * height, 'netSideArea': q['NetSideArea'] * au,
            'deductionVolume': gross - q['NetVolume'] * vu,
            'openingArea': length * height - q['NetSideArea'] * au}
    audit = {'ifcopenshell': ifcopenshell.version, 'scope': 'IfcElement; inherited psets + qtos; recursive id removal',
             'counts': {'v1': len(a), 'v2': len(b), 'shared': len(shared),
                        'removed': len(a.keys() - b.keys()), 'added': len(b.keys() - a.keys()),
                        'changedAll': sum(o['changedAll'] for o in scene_objects),
                        'changedValues': sum(o['changedValues'] for o in scene_objects),
                        'changedDesign': sum(o['changedDesign'] for o in scene_objects),
                        'recorded': sum(o['status'] == 'recorded' for o in scene_objects)},
             'removedClasses': dict(Counter(a[g].is_a() for g in a.keys() - b.keys())),
             'addedClasses': dict(Counter(b[g].is_a() for g in b.keys() - a.keys())),
             'designPolicy': 'Both-present changed values only; Pset_* IsExternal/ThermalTransmittance/Slope, Qto_* and BaseQuantities',
             'fieldDifferences': dict(field_counts),
             'v1OnlyIncludingNull': field_counts['v1Only'] + field_counts['v1OnlyNull'],
             'valueChanges': [{'ifcClass': cls, 'field': field, 'before': json.loads(before), 'after': json.loads(after), 'count': n}
                              for (cls, field, before, after), n in sorted(changed_details.items())],
             'wallTransitions': [{'before': before, 'after': after, 'count': n}
                                 for (before, after), n in sorted(wall_transitions.items())],
             'fieldChangeCounts': dict(fields.most_common()), 'wall': wall}
    return maps, catalogue, scene_objects, audit


def simplify(vertices, faces):
    """Keep small meshes; convex envelope of complex solids (holes may close).
    Extremely curved hulls use their axis-aligned bounds. Quantities never use this.
    """
    if len(vertices) <= 128:
        return vertices, faces, 'mesh'
    hull = ConvexHull(vertices)
    used = sorted(set(hull.simplices.ravel()))
    if len(used) <= 256:
        index = {v: i for i, v in enumerate(used)}
        return vertices[used], np.array([[index[i] for i in tri] for tri in hull.simplices]), 'convex-hull'
    lo, hi = vertices.min(axis=0), vertices.max(axis=0)
    verts = np.array([[x, y, z] for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])])
    return verts, ConvexHull(verts).simplices, 'bounds'


def export_geometry(maps, files, out):
    settings = ifcopenshell.geom.settings()
    settings.set(settings.USE_WORLD_COORDS, True)
    settings.set('mesher-linear-deflection', 0.02)
    buffer, cache, manifest, failed = bytearray(), {}, {}, []
    methods = Counter()
    for label, m in maps.items():
        shapes, pending = {}, {}
        iterator = ifcopenshell.geom.iterator(settings, files[label], 4, include=list(m.values()))
        if not iterator.initialize():
            raise RuntimeError(f'No geometry for {label}')
        while True:
            shape = iterator.get()
            g = shape.guid
            verts = np.array(shape.geometry.verts).reshape(-1, 3)
            faces = np.array(shape.geometry.faces).reshape(-1, 3)
            if len(verts) and len(faces):
                try:
                    verts, faces, method = simplify(verts, faces)
                except Exception:
                    method = 'mesh'  # preserve actual triangles if a hull is degenerate
                verts = np.round(verts, 3).astype('<f4')
                faces = faces.astype('<u4')
                raw = verts.tobytes() + faces.tobytes()
                pending[g] = (raw, len(verts), len(faces), method)
            if not iterator.next():
                break
        # Stable GlobalId order makes binary offsets independent of worker completion order.
        for g, (raw, nv, nf, method) in sorted(pending.items()):
            h = hashlib.sha256(raw).hexdigest()
            if h not in cache:
                cache[h] = {'offset': len(buffer), 'vertices': nv, 'triangles': nf, 'method': method}
                buffer.extend(raw)
            shapes[g] = cache[h]
            methods[method] += 1
        for g in sorted(m.keys() - shapes.keys()):
            failed.append({'version': label, 'globalId': g, 'ifcClass': m[g].is_a(), 'hasRepresentation': bool(m[g].Representation)})
        manifest[label] = shapes
    (out / 'meshes.bin').write_bytes(buffer)
    return manifest, {'bytes': len(buffer), 'uniqueMeshes': len(cache), 'methods': dict(methods), 'missing': failed}


def write_json(path, value):
    path.write_text(canonical(value) + '\n', encoding='utf-8')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--source-root', type=Path, default=SOURCE_ROOT)
    parser.add_argument('--output', type=Path, default=ROOT / 'data')
    parser.add_argument('--no-geometry', action='store_true', help='Audit only; no scene assets')
    args = parser.parse_args()
    args.output.mkdir(parents=True, exist_ok=True)
    started = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
    paths, files = load_sources(args.source_root)
    maps, catalogue, objects, audit = analyze(files)
    audit['sources'] = {v: {'path': str(p), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'schema': files[v].schema} for v, p in paths.items()}
    if not args.no_geometry:
        meshes, geometry_audit = export_geometry(maps, files, args.output)
        audit['geometry'] = geometry_audit
        write_json(args.output / 'scene.json', {'objects': objects, 'meshes': meshes, 'counts': audit['counts'], 'fieldDifferences': audit['fieldDifferences'], 'valueChanges': audit['valueChanges'], 'wallTransitions': audit['wallTransitions']})
        audit['firstSceneDataBytes'] = (args.output / 'scene.json').stat().st_size + (args.output / 'meshes.bin').stat().st_size
        assert audit['firstSceneDataBytes'] <= 3_000_000, audit['firstSceneDataBytes']
    write_json(args.output / 'objects.json', catalogue)
    # Each IFC GlobalId begins with 0..3. Keep even shared selection URLs below 3 MB of data.
    shard_sizes = {}
    for prefix in '0123':
        file = args.output / f'objects-{prefix}.json'
        write_json(file, [o for o in catalogue if o['globalId'].startswith(prefix)])
        shard_sizes[prefix] = file.stat().st_size
    audit['propertyShardBytes'] = shard_sizes
    if not args.no_geometry:
        audit['firstSceneWithLargestSelectionBytes'] = audit['firstSceneDataBytes'] + max(shard_sizes.values())
        assert audit['firstSceneWithLargestSelectionBytes'] <= 3_000_000

    write_json(args.output / 'wall.json', audit['wall'])
    write_json(args.output / 'audit.json', audit)
    write_json(args.output / 'run.json', {'runId': started, 'ifcopenshell': ifcopenshell.version,
               'scipy': scipy.__version__, 'numpy': np.__version__, 'hardware': 'DGX Spark CPU',
               'operation': 'A4 source extraction', 'release': 'v0.1; not deployed'})
    print(json.dumps({'counts': audit['counts'], 'firstSceneDataBytes': audit.get('firstSceneDataBytes'), 'geometry': audit.get('geometry'), 'catalogueBytes': (args.output / 'objects.json').stat().st_size}, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()
