#!/usr/bin/env python3
"""Create small, missing project networks inside the daemon's verified existing pools."""
import argparse
import ipaddress
import json
import re

from network_contract import ContractFailure, OWNER, create_arguments, existing, plans, verify_created
from network_inventory import InventoryFailure, collect
from network_owned_inventory import ownership_inventory
from network_readback import verify_host_overlap
from network_runtime import NetworkFailure, allocation_lock, compose_configuration, run

SCHEMA = 'gowalk-cicd/backend-networks.v1'


def candidates(inventory):
    blocked = [ipaddress.IPv4Network(value) for value in inventory['excluded']]
    seen = set()
    for pool in inventory['pools']:
        base = ipaddress.IPv4Network(pool['base'])
        size = max(24, pool['size'], base.prefixlen)
        if size > 28 or size - base.prefixlen > 20:
            raise NetworkFailure('pool_size_unsupported')
        for subnet in base.subnets(new_prefix=size):
            if subnet not in seen and not any(subnet.overlaps(value) for value in blocked):
                seen.add(subnet)
                yield str(subnet)


def projection(plan, network, state):
    return {'key': plan['key'], 'name': plan['name'], 'state': state,
            'id': network['Id'] if network else '',
            'subnets': [v['Subnet'] for v in (network.get('IPAM', {}).get('Config') or [])] if network else []}


def preserved(plan, current):
    if current:
        if current['Labels'].get(OWNER):
            pools = current['IPAM']['Config']
            if len(pools) != 1:
                raise ContractFailure('created_network_unverified')
            verify_created(plan, current, pools[0]['Subnet'])
        return projection(plan, current, 'preserved')
    if plan['disposition'] != 'eligible':
        return projection(plan, None, plan['disposition'])
    return None


def settled_rows(selected, inventory):
    rows = [preserved(plan, existing(plan, inventory['networks'])) for plan in selected]
    return rows if all(row is not None for row in rows) else None


def verify_snapshot(before, after, created, subnet):
    for key in ('pools', 'pool_source', 'pool_failure', 'docker_version', 'compose_version'):
        if before.get(key) != after.get(key):
            raise NetworkFailure('host_inventory_changed')
    current = {row['Id']: row for row in after['networks']}
    fields = ('Name', 'Driver', 'Scope', 'Internal', 'Attachable', 'EnableIPv6', 'IPAM', 'Options', 'Labels')
    for row in before['networks']:
        if row['Id'] not in current or any(row.get(key) != current[row['Id']].get(key) for key in fields):
            raise NetworkFailure('existing_network_changed')
    selected = ipaddress.IPv4Network(subnet)
    for row in after['networks']:
        if row['Id'] == created['Id']:
            continue
        for value in (row.get('IPAM', {}).get('Config') or []):
            occupied = ipaddress.ip_network(value['Subnet'])
            if occupied.version == 4 and selected.overlaps(occupied):
                raise NetworkFailure('created_network_overlap')
    verify_host_overlap(after, created, subnet)


def create_one(plan, inventory, reader, command):
    for _ in range(3):
        current = existing(plan, inventory['networks'])
        if current:
            return projection(plan, current, 'reused')
        subnet = next(candidates(inventory), None)
        if subnet is None:
            raise NetworkFailure('existing_pool_capacity_exhausted')
        failed = False
        try:
            command(create_arguments(plan, subnet, inventory['compose_version']), seconds=15)
        except NetworkFailure:
            failed = True
        observed = reader(command)
        current = existing(plan, observed['networks'])
        if current:
            verify_created(plan, current, subnet)
            verify_snapshot(inventory, observed, current, subnet)
            return projection(plan, current, 'created_or_reconciled')
        if not failed:
            raise NetworkFailure('created_network_unverified')
        # Only a newly observed allocation of this candidate permits another attempt.
        old_ids = {row['Id'] for row in inventory['networks']}
        occupied = any(ipaddress.IPv4Network(subnet).overlaps(ipaddress.ip_network(value['Subnet']))
                       for row in observed['networks'] if row['Id'] not in old_ids
                       for value in row['IPAM']['Config'] if ipaddress.ip_network(value['Subnet']).version == 4)
        if not occupied:
            raise NetworkFailure('network_create_unconfirmed')
        inventory = observed
    raise NetworkFailure('network_allocation_conflict')


def prepare(config, app, *, reader=collect, owner_reader=ownership_inventory, command=run, lock=allocation_lock):
    selected = plans(config, app)
    inventory = owner_reader(command, selected)
    rows = settled_rows(selected, inventory)
    if rows is not None:
        return {'schema': SCHEMA, 'ok': True, 'project': app, 'networks': rows}
    rows = []
    with lock():
        # The unlocked read never authorizes allocation; ownership is read again under the lock.
        inventory = owner_reader(command, selected)
        for plan in selected:
            row = preserved(plan, existing(plan, inventory['networks']))
            if row is not None:
                rows.append(row)
            else:
                inventory = reader(command, require_pools=False)
                if inventory.get('pool_failure'):
                    raise NetworkFailure(inventory['pool_failure'])
                # Compose 2 and 5 reuse a project network that carries no config-hash label (docs/BACKEND_NETWORKS.md).
                if not re.fullmatch(r'v?(?:2|5)\.\d+\.\d+', inventory['compose_version']):
                    raise NetworkFailure('compose_version_unsupported')
                rows.append(create_one(plan, inventory, reader, command))
                inventory = reader(command, require_pools=False)
    result = {'schema': SCHEMA, 'ok': True, 'project': app, 'networks': rows}
    result.update({key: inventory[key] for key in ('docker_version', 'compose_version', 'python_version',
                                                 'pool_source', 'observed_at') if key in inventory})
    return result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--app', required=True)
    parser.add_argument('--env-file', action='append', default=[])
    args = parser.parse_args()
    try:
        result = prepare(compose_configuration(args.app, args.env_file), args.app)
    except (ContractFailure, InventoryFailure, NetworkFailure) as error:
        result = {'schema': SCHEMA, 'ok': False, 'failure': error.code}
    except Exception:
        result = {'schema': SCHEMA, 'ok': False, 'failure': 'network_operation_unverified'}
    print(json.dumps(result, separators=(',', ':')))
    return 0 if result['ok'] else 1


if __name__ == '__main__':
    raise SystemExit(main())
