"""EVART-01: immutable event identity/time, private storage and Web wire contract."""
from dataclasses import dataclass
from datetime import datetime
import os
from pathlib import Path
import re
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo

GIB = 1024 ** 3
RESERVATION = 128 * 1024 ** 2
STAGE = 'EVENT_CONFIRMED_BEFORE_ENQUEUE'
STAGE_DESCRIPTION = 'Event confirmed; AI reference clock sampled once before asynchronous enqueue.'


@dataclass(frozen=True)
class ArtifactConfig:
    root: Path
    database: str
    endpoint: str
    public_base: str
    port: int = 5001
    cap: int = 100 * GIB
    warning: int = 80 * GIB
    low_free: int = 50 * GIB
    resume_free: int = 60 * GIB
    delivery_cameras: tuple | None = None
    channel_limit: int = 8
    lifetime: int = 120
    recent: int = 30
    enabled: bool = True
    retention_enabled: bool = False
    external_reserve: int = 0
    cleanup_target: int = 0

    @property
    def cleanup_start(self):
        return self.cap * 95 // 100

    @classmethod
    def from_environment(cls):
        enabled = os.environ.get('CONTEXTRA_EVENT_ARTIFACT_ENABLED', '0') == '1'
        if not enabled and os.environ.get('CONTEXTRA_ARTIFACT_HTTP_ENABLED','0') != '1':
            return None
        cameras = os.environ.get('CONTEXTRA_ARTIFACT_DELIVERY_CAMERAS')
        def number(name, default):
            raw = os.environ.get(name, str(default))
            if not raw.isascii() or not raw.isdecimal() or not 0 <= int(raw) <= 10_000_000_000_000:
                raise ValueError('Invalid Artifact storage setting: '+name)
            return int(raw)
        cap = number('CONTEXTRA_ARTIFACT_CAP_BYTES', 100*GIB)
        low = number('CONTEXTRA_ARTIFACT_MIN_FREE_BYTES', 50*GIB)
        resume = number('CONTEXTRA_ARTIFACT_RESUME_FREE_BYTES', max(low,60*GIB))
        retention = os.environ.get('CONTEXTRA_ARTIFACT_RETENTION_ENABLED', 'false')
        from app.storage_policy import load
        policy = load()
        target = cap*9//10
        if policy:
            row = policy['artifact']
            cap, low = row['max_bytes'], row['min_free_bytes']
            resume = max(low, resume)
            target = row['target_bytes']
            retention = 'true' if row['auto_cleanup'] else 'false'
        if cap < RESERVATION or resume < low or retention not in ('true','false'):
            raise ValueError('Invalid Artifact storage policy.')
        if retention == 'true' and not 0 < target < cap*95//100:
            raise ValueError('Artifact cleanup target must be below the 95 percent start threshold.')
        value = cls(Path(os.environ.get('CONTEXTRA_ARTIFACT_ROOT', '/data/contextra/artifacts')),
                    os.environ['DATABASE_URL'], os.environ['CONTEXTRA_EVENT_ENDPOINT'],
                    os.environ['CONTEXTRA_ARTIFACT_PUBLIC_BASE'].rstrip('/'),
                    # An explicit empty overlay value removes a prior test-only camera allowlist.
                    delivery_cameras=None if cameras is None or not cameras.strip()
                    else tuple(c.strip() for c in cameras.split(',') if c.strip()), enabled=enabled,
                    cap=cap,warning=cap*8//10,low_free=low,resume_free=resume,
                    retention_enabled=retention=='true', cleanup_target=target,
                    external_reserve=number('CONTEXTRA_ARTIFACT_EXTERNAL_RESERVE_BYTES',0))
        for url in (value.endpoint, value.public_base):
            parsed = urlsplit(url)
            if parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
                raise ValueError('Invalid Artifact HTTP configuration.')
        if not value.root.is_absolute() or value.root.is_symlink():
            raise ValueError('Artifact root must be a real absolute directory.')
        return value


def relative_directory(event):
    camera = event['cameraId']
    if type(camera) not in (str, int) or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', str(camera)):
        raise ValueError('Invalid artifact camera identity.')
    event_id = event['eventId']
    if not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', event_id):
        raise ValueError('Invalid Event identity.')
    if event['scenario'] not in ('INTRUSION', 'LOITERING', 'FIRE', 'FALL', 'FIGHT', 'CROWD') or type(event['aiRecordedAtWallMs']) is not int or event['aiRecordedAtWallMs'] <= 0:
        raise ValueError('Invalid confirmed Event.')
    stamp = datetime.fromtimestamp(event['aiRecordedAtWallMs'] / 1000, ZoneInfo('Asia/Seoul'))
    return f'cameras/{camera}/{stamp:%Y-%m-%d}/AI_{stamp:%H%M%S}.{event["aiRecordedAtWallMs"] % 1000:03d}+0900_{event["scenario"]}_{event_id}'


def build_body(event, media, error, base, relative):
    timing = event.get('frameTime') or {}
    wall = timing.get('detectedAtWallMs')
    if type(wall) is not int or wall <= 0:
        from app.event_time_fallback import fallback_time
        wall = fallback_time(event)
        if wall is None:
            return None
    detail = dict(aiRecordedAtWallMs=event['aiRecordedAtWallMs'], aiRecordedStage=STAGE,
                  aiRecordedStageDescription=STAGE_DESCRIPTION, scoreMeaning='TEMPORARY_FIXED',
                  scoreMeasured=False, runId=event['runId'], runGeneration=event['runGeneration'],
                  streamId=event['streamId'], frameSeq=event['frameSeq'], ptsNs=event['ptsNs'],
                  frameTime=timing, trackId=event['trackId'], roiId=event['roiId'],
                  artifact=media, artifactError=error)
    if timing.get('detectedAtWallMs') is None and event.get('eventTimeFallback'):
        detail['eventTime'] = dict(event['eventTimeFallback'], detectedAtWallMs=wall)
    detail['roiLabel'] = (event.get('scenarioConfig') or {}).get(event['scenario'], {}).get('roi_name', event['roiId'])
    if 'ruleId' in event:
        context = event['ruleContext']
        detail.update(roiId=event['roiId'], ruleId=event['ruleId'],
                      roiLabel=context['area']['label'])
    row = dict(eventId=event['eventId'], cameraId=event['cameraId'], eventType=event['scenario'],
               detectedAtWallMs=wall, score=0.7, severityCode='WARNING', detail=detail)
    if event['scenario'] == 'CROWD':
        detail['crowd'] = dict(event['crowd'])
        row['severityCode'] = 'DANGER'
    # Wire names are raw timestamp/clock rate, not PTS or an AI-clock substitute.
    if timing.get('rtpTimestamp') is not None and timing.get('startWallTimeMs') is not None:
        row['rtpTimestamp'] = timing['rtpTimestamp']
        row['startWallTimeMs'] = timing['startWallTimeMs']
    if media.get('clip'):
        row['clipUrl'] = f'{base}/{relative}/clip.mp4'
    if media.get('thumbnail'):
        row['thumbnailUrl'] = f'{base}/{relative}/thumbnail.jpg'
    return {'serverId': event['serverId'], 'events': [row]}


def media_path(root, relative):
    path = Path(relative)
    parts = path.parts
    if (path.is_absolute() or len(parts) != 5 or parts[0] != 'cameras'
            or parts[-1] not in ('clip.mp4', 'thumbnail.jpg')
            or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', parts[1])
            or not re.fullmatch(r'\d{4}-\d{2}-\d{2}', parts[2])
            or not re.fullmatch(r'AI_\d{6}\.\d{3}\+0900_(?:INTRUSION|LOITERING|FIRE|FALL|FIGHT|CROWD)_[A-Za-z0-9_-]{1,100}', parts[3])):
        return None
    candidate = root / path
    if any(p.is_symlink() for p in [candidate, *candidate.parents]):
        return None
    try:
        candidate.resolve().relative_to(root.resolve())
    except ValueError:
        return None
    return candidate if candidate.is_file() else None
