"""Source-faithful globe raster derivative; ODbL timezone data, no vector rounding.

Run with pinned Pillow11.3.0. Coordinates use the source's split-date-line
GeoJSON, drawn unchanged into a declared equirectangular raster. Pixel resolution
is approximate; this is a current boundary snapshot, not historical geography.
"""
import argparse
import hashlib
import io
import json
import pathlib
import resource
import sqlite3
import time
import zipfile
from PIL import Image, ImageDraw, ImageChops, ImageMath

SOURCE_HASHES = {
 'timezones-with-oceans-2026e.geojson.zip':'8eefcbc3df102dc3c174983b784bde82fec00fb4a596a1ed63e8f53a92043885',
 'ne_110m_land.geojson':'9e0729ee253ca7d7a5c4ae9395fb1902264c5377c52e224d13dd85010e2835d9',
}
CATALOG_SHA='776ccf1695dd699b8b1e76a1adca4e6ba4e5b85aa50733bd77a758cc932b9571'


def stream_sha256(source):
    digest=hashlib.sha256()
    for chunk in iter(lambda:source.read(65536),b''): digest.update(chunk)
    return digest.hexdigest()


def iter_features(stream, chunk_size=65536, max_feature_bytes=32000000):
    prefix=''
    while True:
        piece=stream.read(chunk_size)
        if not piece: raise ValueError('Missing features array')
        prefix+=piece
        marker=prefix.find('"features"')
        start=prefix.find('[',marker) if marker>=0 else -1
        if start>=0:
            piece=prefix[start+1:];break
        if len(prefix)>1000000: raise ValueError('Unexpected collection header')
    depth=0;quoted=False;escaped=False;fragments=[];size=0;need_comma=False;after_comma=False
    while True:
        segment=0
        for index,char in enumerate(piece):
            if depth==0:
                if char.isspace(): continue
                if char==']':
                    if after_comma: raise ValueError('Trailing feature comma')
                    suffix=piece[index+1:]+stream.read(65537)
                    if len(suffix)>65536 or suffix.strip()!='}' or stream.read(1): raise ValueError('Incomplete collection or trailing content')
                    return
                if char==',' and need_comma:
                    need_comma=False;after_comma=True;continue
                if char!='{' or need_comma: raise ValueError('Expected feature separator/object')
                segment=index;depth=1;after_comma=False;continue
            if quoted:
                if escaped: escaped=False
                elif char=='\\': escaped=True
                elif char=='"': quoted=False
            elif char=='"': quoted=True
            elif char=='{': depth+=1
            elif char=='}':
                depth-=1
                if depth==0:
                    fragments.append(piece[segment:index+1]);size+=index+1-segment
                    if size>max_feature_bytes: raise ValueError('Feature exceeds memory envelope')
                    feature=json.loads(''.join(fragments))
                    if feature.get('type')!='Feature': raise ValueError('Expected GeoJSON feature')
                    yield feature
                    fragments=[];size=0;segment=index+1;need_comma=True
        if depth:
            fragments.append(piece[segment:]);size+=len(piece)-segment
            if size>max_feature_bytes: raise ValueError('Feature exceeds memory envelope')
        piece=stream.read(chunk_size)
        if not piece: raise ValueError('Truncated feature collection')


def polygons(feature):
    geometry=feature['geometry']
    if geometry['type']=='Polygon': return [geometry['coordinates']]
    if geometry['type']=='MultiPolygon': return geometry['coordinates']
    raise ValueError('Only source polygons accepted')


def paint_polygon(image, rings, value, priority=None):
    width,height=image.size
    points=[[(min(width-1,max(0,(lon+180)/360*width)),min(height-1,max(0,(90-lat)/180*height))) for lon,lat,*_ in ring] for ring in rings]
    outline=points[0]
    left=max(0,int(min(x for x,y in outline)));top=max(0,int(min(y for x,y in outline)))
    right=min(width,int(max(x for x,y in outline))+2);bottom=min(height,int(max(y for x,y in outline))+2)
    if right<=left or bottom<=top: return
    mask=Image.new('1',(right-left,bottom-top),0);draw=ImageDraw.Draw(mask)
    for index,ring in enumerate(points): draw.polygon([(x-left,y-top) for x,y in ring],fill=1 if index==0 else 0)
    box=(left,top,right,bottom)
    if priority is not None:
        outline=rings[0]
        area=abs(sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(outline,outline[1:])))/2
        # At shared/subpixel edges, the smaller original polygon wins the pixel.
        # This preserves small zones without rounding or enlarging source rings.
        eligible=ImageMath.lambda_eval(lambda values:values['existing']>area,existing=priority.crop(box)).convert('L').point(lambda value:255 if value else 0)
        mask=ImageChops.multiply(mask.convert('L'),eligible)
        priority.paste(area,box,mask)
    image.paste(value,box,mask)


def export_cities(catalog, output, check):
    chunks={f'{band}-{column}':[] for band in range(12) for column in range(24)};major=[];count=0
    (output/'cities').mkdir()
    with sqlite3.connect(catalog.resolve().as_uri()+'?mode=ro',uri=True) as database:
        database.row_factory=sqlite3.Row
        for row in database.execute('SELECT code,city,country,timezone,latitude,longitude,population FROM locations WHERE population>=10000 AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180 ORDER BY population DESC,code ASC'):
            item=dict(row);count+=1
            if item['population']>=300000: major.append(item)
            column=min(23,int((item['longitude']+180)//15));band=min(11,int((item['latitude']+90)//15))
            chunks.setdefault(f'{band}-{column}',[]).append(item)
            if count%1000==0: check()
    (output/'cities-major.json').write_text(json.dumps(major,separators=(',',':'),ensure_ascii=False))
    for key,values in chunks.items():
        if len(values)>6000: raise RuntimeError('City chunk exceeds declared envelope')
        (output/'cities'/f'{key}.json').write_text(json.dumps(values,separators=(',',':'),ensure_ascii=False));check()
    return {'cityRows':count,'maxCityChunk':max(map(len,chunks.values()))}


def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--source',type=pathlib.Path,required=True);parser.add_argument('--catalog',type=pathlib.Path,required=True)
    parser.add_argument('--output',type=pathlib.Path,required=True);parser.add_argument('--limit',type=int,default=0)
    args=parser.parse_args();started=time.monotonic()
    resource.setrlimit(resource.RLIMIT_CPU,(120,120));resource.setrlimit(resource.RLIMIT_FSIZE,(50000000,50000000))
    def check():
        if time.monotonic()-started>160 or resource.getrusage(resource.RUSAGE_SELF).ru_maxrss>512*1048576: raise RuntimeError('Preprocessing envelope exceeded')
    for name,digest in SOURCE_HASHES.items():
        with (args.source/name).open('rb') as source:
            if stream_sha256(source)!=digest: raise ValueError('Source hash mismatch: '+name)
        check()
    with args.catalog.open('rb') as source:
        if stream_sha256(source)!=CATALOG_SHA: raise ValueError('Catalogue hash mismatch')
    args.output.mkdir(parents=True,exist_ok=False);check()
    image=Image.new('RGB',(4096,2048),(0,0,0));priority=Image.new('F',image.size,float('inf'));legend=[];vertices=0
    with zipfile.ZipFile(args.source/'timezones-with-oceans-2026e.geojson.zip') as archive:
        if archive.namelist()!=['combined-with-oceans.json']: raise ValueError('Unexpected archive member')
        with archive.open('combined-with-oceans.json') as raw:
            for feature in iter_features(io.TextIOWrapper(raw)):
                check();identifier=len(legend)+1
                if identifier>1023: raise RuntimeError('Zone legend exceeds palette bound')
                for polygon in polygons(feature):
                    vertices+=sum(map(len,polygon));paint_polygon(image,polygon,(identifier%256,identifier//256,0),priority);check()
                legend.append(feature['properties']['tzid']);check()
                if args.limit and len(legend)>=args.limit: break
    image.save(args.output/'zone-ids.png',optimize=False);check()
    land=Image.new('L',(2048,1024),0)
    for feature in json.loads((args.source/'ne_110m_land.geojson').read_text())['features']:
        for polygon in polygons(feature): paint_polygon(land,polygon,255)
        check()
    land.save(args.output/'land.png');check()
    city_counts=export_cities(args.catalog,args.output,check);check()
    metadata={'source':'timezone-boundary-builder','release':'2026e','licence':'ODbL-1.0','sourceURL':'https://github.com/evansiroky/timezone-boundary-builder/releases/tag/2026e','sourceSHA256':SOURCE_HASHES,'catalogSHA256':CATALOG_SHA,'timezoneIds':legend,'rasterSize':[4096,2048],'resolutionDegrees':360/4096,'modifications':'Original polygons and holes rasterized without coordinate simplification; smaller source polygons win shared raster pixels to retain small zones. Zone ID = red + 256 × green; 0 means unassigned. Current boundary snapshot; not historical political geography. City catalogue filtered to population >=10000, spatial chunks, population-desc/code-asc.','credits':{'boundaries':'© OpenStreetMap contributors / timezone-boundary-builder; ODbL 1.0 https://opendatacommons.org/licenses/odbl/1-0/','land':'Natural Earth public domain https://www.naturalearthdata.com/about/terms-of-use/','cities':'GeoNames CC BY 4.0 https://www.geonames.org/; existing bundled catalogue, upstream snapshot date not recorded; identified by exact SHA256.'}}
    (args.output/'metadata.json').write_text(json.dumps(metadata,indent=2));check()
    files=[]
    for path in sorted(args.output.rglob('*')):
        if path.is_file():
            with path.open('rb') as source: digest=stream_sha256(source)
            files.append({'path':str(path.relative_to(args.output)),'bytes':path.stat().st_size,'sha256':digest});check()
    receipt={'status':'TRIAL' if args.limit else 'COMPLETE','zones':len(legend),'sourceVertices':vertices,**city_counts,'seconds':time.monotonic()-started,'ownPeakRSSBytes':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,'files':files}
    check();(args.output/'derivation-receipt.json').write_text(json.dumps(receipt,indent=2));check()
    print(json.dumps({key:value for key,value in receipt.items() if key!='files'}))
if __name__=='__main__': main()
