# fam.py — the fam. API client, generated from fam. API v1 (38 paths).
# Base URL: https://app.fam.services/api/v1. Do not edit: download it again when the reference changes.
# A refusal raises FamError with error, message, param, reason, call_id, doc_url; a 429 is retried after Retry-After.

import json
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request

OPS = [{"key":"time-off.requests.create","words":["time_off","requests","create"],"method":"POST","path":"/time-off/requests","params":[],"query":[],"body":["time_off_type_id","from","to","part","from_min","to_min","substitute_id","comment","attachments"],"list":False,"upload":False},{"key":"time-off.requests.delete","words":["time_off","requests","delete"],"method":"DELETE","path":"/time-off/requests/{id}","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"time-off.requests.approve","words":["time_off","requests","approve"],"method":"POST","path":"/time-off/requests/{id}/approve","params":["id"],"query":[],"body":["comment"],"list":False,"upload":False},{"key":"time-off.requests.reject","words":["time_off","requests","reject"],"method":"POST","path":"/time-off/requests/{id}/reject","params":["id"],"query":[],"body":["comment"],"list":False,"upload":False},{"key":"time-off.days","words":["time_off","days"],"method":"GET","path":"/time-off/days","params":[],"query":["from","to"],"body":[],"list":False,"upload":False},{"key":"tickets.update","words":["tickets","update"],"method":"PATCH","path":"/tickets/{id}","params":["id"],"query":[],"body":["description","purchase_order","invoice","cost_center","nominal_code","invoice_date","requestor_internal_id","assignee_internal_id","visit_id","apply_all"],"list":False,"upload":False},{"key":"tickets.read","words":["tickets","read"],"method":"GET","path":"/tickets/{id}","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"tickets.comments.create","words":["tickets","comments","create"],"method":"POST","path":"/tickets/{id}/comments","params":["id"],"query":[],"body":["comment","reply_to_id","attachments","attachment_type","awaiting_feedback_id","status_id","completion_date","completion_date_suggested","schedule_date","assignee_id","field_operator_id","visit_id"],"list":False,"upload":False},{"key":"comments.update","words":["comments","update"],"method":"PUT","path":"/comments/{id}","params":["id"],"query":[],"body":["comment"],"list":False,"upload":False},{"key":"comments.delete","words":["comments","delete"],"method":"DELETE","path":"/comments/{id}","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"tickets.attachments","words":["tickets","attachments"],"method":"GET","path":"/tickets/{id}/attachments","params":["id"],"query":["visit_id"],"body":[],"list":False,"upload":False},{"key":"tickets.mentions","words":["tickets","mentions"],"method":"GET","path":"/tickets/{id}/mentions","params":["id"],"query":["field_operator_id","assignee_id","assignee_type"],"body":[],"list":False,"upload":False},{"key":"tickets.search","words":["tickets","search"],"method":"GET","path":"/tickets/search","params":[],"query":["q","exclude","limit"],"body":[],"list":False,"upload":False},{"key":"tickets.nudge","words":["tickets","nudge"],"method":"POST","path":"/tickets/{id}/nudge","params":["id"],"query":[],"body":["user_ids","reason"],"list":False,"upload":False},{"key":"me.read","words":["me","read"],"method":"GET","path":"/me","params":[],"query":[],"body":[],"list":False,"upload":False},{"key":"tickets.list","words":["tickets","list"],"method":"GET","path":"/tickets","params":[],"query":["status","location_id","requester_id","updated_since","limit","cursor"],"body":[],"list":True,"upload":False},{"key":"tickets.create","words":["tickets","create"],"method":"POST","path":"/tickets","params":[],"query":[],"body":["location_id","request_id","asset_id","area_connection_id","description","priority_id","emergency","attachments"],"list":False,"upload":False},{"key":"locations.list","words":["locations","list"],"method":"GET","path":"/locations","params":[],"query":["q","limit","cursor"],"body":[],"list":True,"upload":False},{"key":"locations.read","words":["locations","read"],"method":"GET","path":"/locations/{id}","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"locations.requests","words":["locations","requests"],"method":"GET","path":"/locations/{id}/requests","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"assets.list","words":["assets","list"],"method":"GET","path":"/assets","params":[],"query":["location_id","q","limit","cursor"],"body":[],"list":True,"upload":False},{"key":"assets.read","words":["assets","read"],"method":"GET","path":"/assets/{id}","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"users.list","words":["users","list"],"method":"GET","path":"/users","params":[],"query":["q","limit","cursor"],"body":[],"list":True,"upload":False},{"key":"users.read","words":["users","read"],"method":"GET","path":"/users/{id}","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"bookings.create","words":["bookings","create"],"method":"POST","path":"/bookings","params":[],"query":[],"body":["asset_id","area_connection_id","user_id","start_local","end_local","start_at","end_at","repeat_until_local"],"list":False,"upload":False},{"key":"bookings.mine","words":["bookings","mine"],"method":"GET","path":"/bookings/mine","params":[],"query":["limit","cursor","past"],"body":[],"list":True,"upload":False},{"key":"bookings.availability","words":["bookings","availability"],"method":"GET","path":"/bookings/availability","params":[],"query":["location_id","from","to"],"body":[],"list":False,"upload":False},{"key":"bookings.delete","words":["bookings","delete"],"method":"DELETE","path":"/bookings/{id}","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"bookings.check-in","words":["bookings","check_in"],"method":"POST","path":"/bookings/{id}/check-in","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"bookings.approve","words":["bookings","approve"],"method":"POST","path":"/bookings/{id}/approve","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"bookings.reject","words":["bookings","reject"],"method":"POST","path":"/bookings/{id}/reject","params":["id"],"query":[],"body":[],"list":False,"upload":False},{"key":"attachments.signed-url","words":["attachments","signed_url"],"method":"GET","path":"/attachments/signed-url","params":[],"query":["kind","record_id","file_name","location_id","organization_id","entry_id","verify","download","download_name","deleted"],"body":[],"list":False,"upload":False},{"key":"files.create","words":["files","create"],"method":"POST","path":"/files","params":[],"query":[],"body":["file"],"list":False,"upload":True},{"key":"notifications.feed","words":["notifications","feed"],"method":"GET","path":"/notifications","params":[],"query":["tab","view","sort","limit","cursor"],"body":[],"list":True,"upload":False},{"key":"notifications.mark-read","words":["notifications","mark_read"],"method":"POST","path":"/notifications/mark-read","params":[],"query":[],"body":["notification_ids"],"list":False,"upload":False},{"key":"notifications.mark-unread","words":["notifications","mark_unread"],"method":"POST","path":"/notifications/mark-unread","params":[],"query":[],"body":["notification_ids"],"list":False,"upload":False},{"key":"notifications.mark-done","words":["notifications","mark_done"],"method":"POST","path":"/notifications/mark-done","params":[],"query":[],"body":["notification_ids"],"list":False,"upload":False},{"key":"notifications.undo-done","words":["notifications","undo_done"],"method":"POST","path":"/notifications/undo-done","params":[],"query":[],"body":["notification_ids"],"list":False,"upload":False},{"key":"notifications.snooze","words":["notifications","snooze"],"method":"POST","path":"/notifications/snooze","params":[],"query":[],"body":["notification_ids","until"],"list":False,"upload":False},{"key":"notifications.mark-all-read","words":["notifications","mark_all_read"],"method":"POST","path":"/notifications/mark-all-read","params":[],"query":[],"body":["tab"],"list":False,"upload":False},{"key":"search.query","words":["search","query"],"method":"GET","path":"/search","params":[],"query":["q","type","limit"],"body":[],"list":False,"upload":False}]

BASE = "https://app.fam.services/api/v1"
VERSION = "v1"


class FamError(Exception):
    """A refusal: error (the word to branch on), message, param, reason, call_id, doc_url, status, body."""

    def __init__(self, status, body, headers=None):
        body = body or {}
        super().__init__(body.get('message') or body.get('error') or f'HTTP {status}')
        self.status = status
        self.error = body.get('error')
        self.message = body.get('message') or ''
        self.param = body.get('param')
        self.reason = body.get('reason')
        self.call_id = body.get('call_id') or (headers.get('Request-Id') if headers else None)
        self.doc_url = body.get('doc_url')
        self.body = body


def _multipart(fields):
    """A multipart/form-data body: a file field is a path, an open file, or (name, bytes, content_type)."""
    boundary = uuid.uuid4().hex
    out = bytearray()
    for name, value in fields.items():
        out += f'--{boundary}\r\n'.encode()
        if isinstance(value, tuple):
            file_name, content, content_type = value
        elif hasattr(value, 'read'):
            file_name, content, content_type = getattr(value, 'name', 'file').rsplit('/', 1)[-1], value.read(), 'application/octet-stream'
        elif isinstance(value, str) and name == 'file':
            with open(value, 'rb') as f:
                file_name, content, content_type = value.rsplit('/', 1)[-1], f.read(), 'application/octet-stream'
        else:
            out += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + str(value).encode() + b'\r\n'
            continue
        out += f'Content-Disposition: form-data; name="{name}"; filename="{file_name}"\r\nContent-Type: {content_type}\r\n\r\n'.encode()
        out += content if isinstance(content, bytes) else content.encode()
        out += b'\r\n'
    out += f'--{boundary}--\r\n'.encode()
    return bytes(out), f'multipart/form-data; boundary={boundary}'


class _Call:
    def __init__(self, client, op):
        self._client, self._op = client, op

    def __call__(self, idempotency_key=None, **args):
        return self._client.call(self._op, args, idempotency_key=idempotency_key)

    def pages(self, **args):
        """Every row of a list, page after page (limit + cursor -> has_more + next_cursor)."""
        if not self._op['list']:
            raise TypeError(f"{self._op['key']} is not a list")
        cursor = args.pop('cursor', None)
        while True:
            page = self._client.call(self._op, dict(args, **({'cursor': cursor} if cursor else {})))
            rows = next((v for v in page.values() if isinstance(v, list)), [])
            for row in rows:
                yield row
            cursor = page.get('next_cursor') if page.get('has_more') else None
            if not cursor:
                return


class _Node:
    pass


class Fam:
    """fam = Fam('<api key>'); fam.bookings.mine(limit=10); fam.bookings.create(asset_id=..., start_local=..., end_local=...)"""

    def __init__(self, key, base=BASE, retries=2, timeout=30):
        if not key:
            raise ValueError('fam: an API key is required')
        self.key, self.base, self.retries, self.timeout = key, base.rstrip('/'), retries, timeout
        self.version = VERSION
        for op in OPS:
            node = self
            for w in op['words'][:-1]:
                if not hasattr(node, w):
                    setattr(node, w, _Node())
                node = getattr(node, w)
            setattr(node, op['words'][-1], _Call(self, op))

    def call(self, op, args, idempotency_key=None):
        path = op['path']
        for p in op['params']:
            if args.get(p) is None:
                raise ValueError(f"fam: {op['key']} needs {p}")
            path = path.replace('{' + p + '}', urllib.parse.quote(str(args[p]), safe=''))
        query = {w: args[w] for w in op['query'] if args.get(w) is not None}
        url = self.base + path + ('?' + urllib.parse.urlencode({k: str(v).lower() if isinstance(v, bool) else v for k, v in query.items()}) if query else '')
        body = {w: args[w] for w in op['body'] if w in args} if op['body'] else None
        headers = {'Authorization': f'Bearer {self.key}', 'Accept': 'application/json'}
        data = None
        if body is not None and op.get('upload'):
            data, headers['Content-Type'] = _multipart(body)
        elif body is not None:
            headers['Content-Type'] = 'application/json'
            data = json.dumps(body).encode()
        if op['method'] != 'GET':
            headers['Idempotency-Key'] = idempotency_key or str(uuid.uuid4())
        attempt = 0
        while True:
            req = urllib.request.Request(url, data=data, method=op['method'], headers=headers)
            try:
                with urllib.request.urlopen(req, timeout=self.timeout) as res:
                    status, text, hdrs = res.status, res.read().decode(), res.headers
            except urllib.error.HTTPError as e:
                status, text, hdrs = e.code, e.read().decode(), e.headers
            try:
                payload = json.loads(text) if text else None
            except ValueError:
                payload = None
            if status == 429 and attempt < self.retries:
                time.sleep(float(hdrs.get('Retry-After') or 1))
                attempt += 1
                continue
            if status >= 400 or not payload or payload.get('ok') is not True:
                raise FamError(status, payload, hdrs)
            return payload
