"""Read-only access to MINT cohort and asset definitions. Python 3.8+, standard library only."""
import copy
import json
import re
from urllib.error import URLError
from urllib.parse import urlparse
from urllib.request import urlopen


class CohortError(ValueError):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


def _text(value):
    return isinstance(value, str) and bool(value)


def _source_ref(value):
    return isinstance(value, dict) and _text(value.get("sourceId")) and isinstance(value.get("sha256"), str) and re.fullmatch(r"[a-f0-9]{64}", value["sha256"]) is not None and type(value.get("startLine")) is int and value["startLine"] > 0 and type(value.get("endLine")) is int and value["endLine"] >= value["startLine"]


def validate_registry(value):
    if not isinstance(value, dict) or value.get("catalogId") != "mint-shadow-cohorts" or value.get("version") != "0.1.0" or value.get("scope") != "definition-discovery" or not isinstance(value.get("records"), list) or len(value["records"]) != 12:
        raise CohortError("INVALID_REGISTRY", "Expected MINT cohort registry version 0.1.0.")
    ids = set()
    for row in value["records"]:
        strings = ("id", "version", "name", "assetStructure", "definition", "decisionQuestion")
        arrays = ("aliases", "assetStructureAliases", "evidenceToInspect")
        rules = ("entryRule", "exclusionRule", "amountRule", "releaseRule")
        if not isinstance(row, dict) or any(not isinstance(row.get(key), str) or not row[key] for key in strings) or row["version"] != value["version"] or any(not isinstance(row.get(key), list) or any(not isinstance(item, str) for item in row[key]) for key in arrays) or not isinstance(row.get("legacyIds"), dict) or not isinstance(row["legacyIds"].get("shadowDashboard"), str) or re.fullmatch(r"0[1-9]|1[0-2]", row["legacyIds"]["shadowDashboard"]) is None or not isinstance(row.get("referenceScenario"), dict) or not _text(row["referenceScenario"].get("nextReleaseEvent")) or row["referenceScenario"].get("sourceId") != "shadow-dashboard" or not isinstance(row.get("sourceRefs"), list) or len(row["sourceRefs"]) < 2 or not all(_source_ref(ref) for ref in row["sourceRefs"]) or not row["evidenceToInspect"] or any(not all(_text(item) for item in row[key]) for key in arrays) or any(key not in row or row[key] is not None for key in rules) or row["id"] in ids:
            raise CohortError("INVALID_REGISTRY", "A cohort definition has invalid fields or a duplicate ID.")
        ids.add(row["id"])
    return value


def load_cohorts(base_url, timeout=10):
    if not isinstance(base_url, str) or urlparse(base_url).scheme not in ("http", "https") or not urlparse(base_url).netloc or urlparse(base_url).username or urlparse(base_url).password or urlparse(base_url).query or urlparse(base_url).fragment:
        raise CohortError("INVALID_URL", "Use an HTTP or HTTPS docs URL.")
    url = base_url.rstrip("/") + "/cohorts.json"
    try:
        with urlopen(url, timeout=timeout) as response:
            value = json.load(response)
    except (URLError, OSError, ValueError) as error:
        raise CohortError("FETCH_FAILED", "Cannot load the cohort registry: " + str(error)) from error
    return validate_registry(value)


def list_cohorts(registry):
    return [{key: row[key] for key in ("id", "name", "assetStructure")} for row in validate_registry(registry)["records"]]


def get_cohort(registry, cohort_id):
    for row in validate_registry(registry)["records"]:
        if row["id"] == cohort_id:
            return copy.deepcopy(row)
    raise CohortError("COHORT_NOT_FOUND", "Unknown cohort ID: " + str(cohort_id))


def search_cohorts(registry, query):
    if not isinstance(query, str) or not query.strip():
        raise CohortError("QUERY_REQUIRED", "Supply search text.")
    term = query.lower()
    return [copy.deepcopy(row) for row in validate_registry(registry)["records"] if any(term in value.lower() for value in [row["id"], row["name"], row["assetStructure"], row["definition"], *row["aliases"], *row["assetStructureAliases"], *row["evidenceToInspect"]])]

# Asset fields are proposed inspection descriptors. The catalog contains no values.
_ASSET_IDS = set(('return-window-survivors', 'late-delivery-claims', 'partially-fulfilled-pos', 'delivered-awaiting-acceptance', 'retail-chargebacks', 'volume-rebates', 'promotional-allowances', 'predictable-short-pays', 'delivered-not-invoiced', 'milestones-retainage', 'formula-priced-shipments', 'setoff-netting'))


def _exact_keys(value, keys):
    return isinstance(value, dict) and set(value) == set(keys)


def _strict_source_ref(ref):
    return _exact_keys(ref, ('sourceId', 'sha256', 'startLine', 'endLine')) and _source_ref(ref)


def _asset_field(field):
    return _exact_keys(field, ('key', 'scope', 'type', 'unit', 'description', 'sourceBasis')) and isinstance(field['key'], str) and re.fullmatch(r'[a-z][A-Za-z0-9]*', field['key']) is not None and field['scope'] in ('shared', 'asset-type') and field['type'] in ('decimal-string', 'date') and field['unit'] == ('ISO-8601-date' if field['type'] == 'date' else 'source-currency') and _text(field['description']) and isinstance(field['sourceBasis'], list) and bool(field['sourceBasis']) and all(_strict_source_ref(ref) for ref in field['sourceBasis'])


def validate_asset_definitions(value):
    def invalid():
        raise CohortError('INVALID_REGISTRY', 'Expected valid MINT asset definitions version 0.1.0.')
    if not _exact_keys(value, ('catalogId', 'version', 'scope', 'fieldStatus', 'valueStatus', 'records')) or value['catalogId'] != 'mint-asset-definitions' or value['version'] != '0.1.0' or value['scope'] != 'definition-discovery' or value['fieldStatus'] != 'proposed-source-backed-descriptors' or value['valueStatus'] != 'no-observed-values' or not isinstance(value['records'], list) or len(value['records']) != 12:
        invalid()
    ids = set()
    for row in value['records']:
        if not _exact_keys(row, ('id', 'version', 'cohortId', 'name', 'purpose', 'evidenceToInspect', 'fields', 'sourceRefs', 'entryRule', 'exclusionRule', 'amountRule', 'releaseRule')) or not isinstance(row['id'], str) or row['id'] not in _ASSET_IDS or row['cohortId'] != row['id'] or row['version'] != value['version'] or not _text(row['name']) or not _text(row['purpose']) or not isinstance(row['evidenceToInspect'], list) or not row['evidenceToInspect'] or not all(_text(item) for item in row['evidenceToInspect']) or not isinstance(row['fields'], list) or len(row['fields']) < 5 or not all(_asset_field(field) for field in row['fields']) or len({field['key'] for field in row['fields']}) != len(row['fields']) or not isinstance(row['sourceRefs'], list) or len(row['sourceRefs']) < 2 or not all(_strict_source_ref(ref) for ref in row['sourceRefs']) or any(row[key] is not None for key in ('entryRule', 'exclusionRule', 'amountRule', 'releaseRule')) or row['id'] in ids:
            invalid()
        for field in row['fields']:
            for ref in field['sourceBasis']:
                if not any(source['sourceId'] == ref['sourceId'] and source['sha256'] == ref['sha256'] and source['startLine'] <= ref['startLine'] and source['endLine'] >= ref['endLine'] for source in row['sourceRefs']):
                    invalid()
        ids.add(row['id'])
    return value


def load_asset_definitions(base_url, timeout=10):
    try:
        parsed = urlparse(base_url) if isinstance(base_url, str) else None
        if parsed is None or parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
            raise ValueError()
        parsed.port
    except ValueError:
        raise CohortError('INVALID_URL', 'Use an HTTP or HTTPS docs URL.')
    url = base_url.rstrip('/') + '/asset-definitions.json'
    try:
        with urlopen(url, timeout=timeout) as response:
            value = json.load(response)
    except (URLError, OSError, ValueError) as error:
        if hasattr(error, 'close'):
            error.close()
        raise CohortError('FETCH_FAILED', 'Cannot load asset definitions: ' + str(error)) from error
    return validate_asset_definitions(value)


def list_asset_definitions(catalog):
    return [{key: row[key] for key in ('id', 'name', 'cohortId')} for row in validate_asset_definitions(catalog)['records']]


def get_asset_definition(catalog, definition_id):
    for row in validate_asset_definitions(catalog)['records']:
        if row['id'] == definition_id:
            return copy.deepcopy(row)
    raise CohortError('ASSET_DEFINITION_NOT_FOUND', 'Unknown asset definition ID: ' + str(definition_id))


def search_asset_definitions(catalog, query):
    if not isinstance(query, str) or not query.strip():
        raise CohortError('QUERY_REQUIRED', 'Supply search text.')
    term = query.lower()
    return [copy.deepcopy(row) for row in validate_asset_definitions(catalog)['records'] if any(term in value.lower() for value in [row['id'], row['cohortId'], row['name'], row['purpose'], *row['evidenceToInspect'], *[text for field in row['fields'] for text in (field['key'], field['description'])]])]
