"""Karaoke mode — vocal isolation via unsandbox zerotrust containers.

Downloads media from S3, uploads it + voxsplit.c via the /upload endpoint
(streaming, constant memory), then kicks off /execute referencing upload_ids.
Gets back both instrumentals and vocals, uploads them to S3.

Upload-based pipeline: files stream directly from disk to the API in 64KB
chunks — never base64-encoded, never held in memory on either side.
"""

import base64
import fcntl
import hashlib
import hmac
import json
import logging
import os
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests

from .un import API_BASE, _resolve_credentials, validate_keys

log = logging.getLogger(__name__)

_READ_CHUNK = 65536  # 64KB streaming buffer


VOXSPLIT_SOURCE = os.environ.get(
    "VOXSPLIT_SOURCE_PATH",
    os.path.join(os.path.dirname(__file__), "voxsplit.c"),
)


def _sign_body(secret_key, timestamp, method, path, body_bytes):
    """HMAC-SHA256 matching un._sign_request for small payloads."""
    h = hmac.new(secret_key.encode(), digestmod=hashlib.sha256)
    h.update(f"{timestamp}:{method}:{path}:{body_bytes}".encode())
    return h.hexdigest()



def _upload_file(file_path, filename, public_key, secret_key):
    """Upload a file via POST /upload (streaming, constant memory).

    Returns the upload_id string.

    Note: the API skips body parsing for /upload (body streams in chunks),
    so the HMAC signs against an empty body — not the file content.
    """
    timestamp = int(time.time())
    method = "POST"
    path = "/upload"

    # API doesn't buffer upload bodies for HMAC — sign with empty body
    signature = _sign_body(secret_key, timestamp, method, path, "")

    headers = {
        "Authorization": f"Bearer {public_key}",
        "X-Timestamp": str(timestamp),
        "X-Signature": signature,
        "X-Filename": filename,
        "Content-Type": "application/octet-stream",
    }

    url = f"{API_BASE}{path}"

    with open(file_path, "rb") as body:
        response = requests.post(
            url, data=body, headers=headers, stream=False, timeout=300,
        )

    response.raise_for_status()
    result = response.json()
    return result["upload_id"]


def _execute_with_uploads(upload_ids, script, public_key, secret_key, tmpdir):
    """POST /execute referencing upload_ids instead of inline content.

    The JSON body contains only metadata — no file content. Returns path
    to response.json on disk.
    """
    payload = {
        "language": "bash",
        "code": script,
        "network_mode": "zerotrust",
        "ttl": 300,
        "vcpu": 2,
        "input_files": [
            {"upload_id": uid, "filename": fname}
            for fname, uid in upload_ids
        ],
    }

    body_str = json.dumps(payload)
    timestamp = int(time.time())
    method = "POST"
    path = "/execute"

    signature = _sign_body(secret_key, timestamp, method, path, body_str)

    headers = {
        "Authorization": f"Bearer {public_key}",
        "X-Timestamp": str(timestamp),
        "X-Signature": signature,
        "Content-Type": "application/json",
    }

    url = f"{API_BASE}{path}"
    response = requests.post(
        url, data=body_str.encode(), headers=headers, stream=True, timeout=300,
    )

    response.raise_for_status()

    resp_path = os.path.join(tmpdir, "response.json")
    with open(resp_path, "wb") as f:
        for chunk in response.iter_content(chunk_size=_READ_CHUNK):
            if chunk:
                f.write(chunk)

    return resp_path


def _process_response_from_disk(resp_path, s3_client, bucket, s3_path,
                                is_video, extension, tmpdir):
    """Parse response JSON, decode artifacts to tmpfiles, upload to S3.

    Returns {"instrumentals": size, "vocals": size} or None.
    """
    with open(resp_path, "r") as f:
        result = json.load(f)
    os.unlink(resp_path)

    if not result.get("success") or result.get("exit_code") != 0:
        log.warning(
            "Unsandbox execute non-zero exit: success=%s exit_code=%s "
            "stdout=%.2000s stderr=%.2000s",
            result.get("success"), result.get("exit_code"),
            result.get("stdout", ""), result.get("stderr", ""),
        )
        return None

    artifacts = result.get("artifacts", [])
    if len(artifacts) < 2:
        return None

    artifact_map = {}
    for a in artifacts:
        fname = a.get("filename", "")
        if fname.startswith("instrumentals"):
            artifact_map["instrumentals"] = a
        elif fname.startswith("vocals"):
            artifact_map["vocals"] = a

    if "instrumentals" not in artifact_map or "vocals" not in artifact_map:
        return None

    sizes = {}
    content_type = "video/mp4" if is_video else "audio/wav"

    for track_name in ("instrumentals", "vocals"):
        artifact = artifact_map[track_name]
        decoded = base64.b64decode(artifact["content_base64"])

        track_path = os.path.join(tmpdir, f"{track_name}.bin")
        with open(track_path, "wb") as f:
            f.write(decoded)
        size = len(decoded)
        del decoded
        del artifact["content_base64"]

        with open(track_path, "rb") as f:
            s3_client.put_object(
                Bucket=bucket,
                Key=f"{s3_path}/{track_name}",
                Body=f,
                CacheControl="private, max-age=172800",
                ContentType=content_type,
            )
        sizes[track_name] = size
        os.unlink(track_path)

    return sizes


def process_karaoke(s3_client, bucket, s3_key, s3_path,
                    is_video, extension, public_key=None, secret_key=None,
                    skip_validate=False):
    """Download from S3 -> process in unsandbox zerotrust -> upload both tracks.

    Upload-based pipeline: files stream to the API via POST /upload (64KB
    chunks, constant memory on both sides), then /execute references them
    by upload_id. Zero base64 encoding on the request side.

    Produces two files under s3_path:
        {s3_path}/instrumentals
        {s3_path}/vocals

    Returns dict {"instrumentals": size, "vocals": size} on success, None on failure.
    """
    public_key, secret_key = _resolve_credentials(public_key, secret_key)

    if not skip_validate:
        try:
            validate_keys(public_key, secret_key)
        except Exception as e:
            log.warning("Unsandbox API keys invalid or expired — skipping: %s", e)
            return None

    log.info("process_karaoke: bucket=%s key=%s s3_path=%s", bucket, s3_key, s3_path)

    with tempfile.TemporaryDirectory(prefix="karaoke_") as tmpdir:
        # 1. Stream S3 download to disk
        media_path = os.path.join(tmpdir, "media.bin")
        resp = s3_client.get_object(Bucket=bucket, Key=s3_key)
        with open(media_path, "wb") as f:
            body = resp["Body"]
            while True:
                chunk = body.read(_READ_CHUNK)
                if not chunk:
                    break
                f.write(chunk)

        # 2. Prepare voxsplit binary
        voxsplit_bin = os.path.join(tmpdir, "voxsplit.bin")
        with open(VOXSPLIT_SOURCE, "r") as src, open(voxsplit_bin, "wb") as dst:
            dst.write(src.read().encode())

        # 3. Upload both files via /upload (streaming, constant memory)
        try:
            media_upload_id = _upload_file(
                media_path, "media", public_key, secret_key,
            )
            os.unlink(media_path)

            voxsplit_upload_id = _upload_file(
                voxsplit_bin, "voxsplit.c", public_key, secret_key,
            )
            os.unlink(voxsplit_bin)
        except Exception as e:
            log.warning("Unsandbox upload failed: %s", e)
            return None

        # 4. Build execution script
        extract_wav = (
            "gcc -O2 -o /tmp/voxsplit /tmp/input/voxsplit.c -lm && "
            "ffmpeg -y -i /tmp/input/media -vn -acodec pcm_s16le -ar 44100 -ac 2 /tmp/audio.wav && "
            "/tmp/voxsplit /tmp/audio.wav -o /tmp/split && "
            "mkdir -p /tmp/artifacts"
        )
        if is_video:
            inst_name = f"instrumentals.{extension}"
            vocals_name = f"vocals.{extension}"
            script = (
                f"{extract_wav} && "
                f"ffmpeg -y -i /tmp/input/media -i /tmp/split-instrumental.wav "
                f"-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/{inst_name} && "
                f"ffmpeg -y -i /tmp/input/media -i /tmp/split-vocal.wav "
                f"-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/{vocals_name}"
            )
        else:
            script = (
                f"{extract_wav} && "
                "cp /tmp/split-instrumental.wav /tmp/artifacts/instrumentals.wav && "
                "cp /tmp/split-vocal.wav /tmp/artifacts/vocals.wav"
            )

        # 5. Execute with upload_id references (tiny JSON body, no file content)
        try:
            resp_path = _execute_with_uploads(
                [("media", media_upload_id), ("voxsplit.c", voxsplit_upload_id)],
                script, public_key, secret_key, tmpdir,
            )
        except Exception as e:
            log.warning("Unsandbox execute failed: %s", e)
            return None

        # 6. Process response, upload artifacts to S3
        return _process_response_from_disk(
            resp_path, s3_client, bucket, s3_path, is_video, extension, tmpdir,
        )


def tracks_exist(s3_client, bucket, s3_path):
    """Check if instrumentals and vocals already exist in S3."""
    for track in ("instrumentals", "vocals"):
        try:
            s3_client.head_object(Bucket=bucket, Key=f"{s3_path}/{track}")
        except Exception:
            return False
    return True


def capture_karaoke_config(shop, app_settings):
    """Snapshot every value process_karaoke_detached needs, into plain dicts.

    Called in the parent request while the shop ORM object is live. The
    grandchild process receives only immutable values — no DB reads
    required until the final metadata write (after karaoke completes,
    minutes later, by which time the parent has long committed).

    app_settings: the app-prefix-stripped settings dict — i.e. request.app
    (NOT request.registry.settings, which keeps the "app." prefix in prod).
    """
    if shop.has_primary_s3:
        s3_creds = {
            "region": shop.primary_s3_region,
            "endpoint": shop.primary_s3_endpoint,
            "access_key": shop.primary_s3_access_key,
            "secret_key": shop.primary_s3_secret_key,
            "bucket": shop.primary_s3_bucket,
        }
    else:
        s3_creds = {
            "region": app_settings["bucket.secure_uploads.region"],
            "endpoint": app_settings["bucket.secure_uploads.post_endpoint"],
            "access_key": app_settings["bucket.secure_uploads.access_key"],
            "secret_key": app_settings["bucket.secure_uploads.secret_key"],
            "bucket": app_settings["bucket.secure_uploads"],
        }

    mirror_creds = None
    if shop.has_s3_mirror:
        mirror_creds = {
            "region": shop.mirror_s3_region,
            "endpoint": shop.mirror_s3_endpoint,
            "access_key": shop.mirror_s3_access_key,
            "secret_key": shop.mirror_s3_secret_key,
            "bucket": shop.mirror_s3_bucket,
        }

    return {
        "unsandbox_pk": shop.unsandbox_public_key,
        "unsandbox_sk": shop.unsandbox_secret_key,
        "s3": s3_creds,
        "mirror": mirror_creds,
    }


def process_karaoke_detached(product_id, file_key, extension, s3_path,
                             karaoke_config, db_url):
    """Fire-and-forget karaoke processing via detached child process.

    Double-forks so the worker survives uWSGI recycling, then runs
    process_karaoke on the given source file and updates the product's
    file_metadata + file_bytes with the resulting instrumentals/vocals.

    Per-product lockfile (/tmp/karaoke_{product_id}.lock) prevents
    concurrent runs for the same product — a second call while one is
    in flight silently returns.

    All shop config (unsandbox keys, S3 creds, mirror creds) is
    captured by the caller via capture_karaoke_config() before the
    fork, so the grandchild never reads uncommitted data across the
    fork boundary. The grandchild's only DB contact is a write at the
    end — after karaoke completes (minutes), by which time the parent
    request has long since committed.

    Returns immediately in the parent. All errors in the grandchild
    are logged, never raised.
    """
    from ..models.product import get_media_type

    lockfile = f"/tmp/karaoke_{product_id}.lock"

    # Pre-check in parent: skip fork if another karaoke is already running
    try:
        check_fd = open(lockfile, "w")
        fcntl.flock(check_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        fcntl.flock(check_fd, fcntl.LOCK_UN)
        check_fd.close()
    except (IOError, OSError):
        return

    pid = os.fork()
    if pid > 0:
        os.waitpid(pid, 0)
        return

    # Intermediate child: detach from uWSGI
    os.setsid()
    pid2 = os.fork()
    if pid2 > 0:
        os._exit(0)

    # --- Grandchild: fully detached ---
    import resource
    maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
    if maxfd == resource.RLIM_INFINITY:
        maxfd = 1024
    for fd in range(3, maxfd):
        try:
            os.close(fd)
        except OSError:
            pass

    lock_fd = None
    try:
        lock_fd = open(lockfile, "w")
        fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        lock_fd.write(str(os.getpid()))
        lock_fd.flush()

        import boto3
        from sqlalchemy import create_engine
        from sqlalchemy.orm import Session as SASession
        from ..models.product import Product
        from .s3_mirror import _make_mirror_client, mirror_key

        is_video = (get_media_type(extension) == "video")
        s3_key = f"{s3_path}/{file_key}"

        s3_cfg = karaoke_config["s3"]
        bucket = s3_cfg["bucket"]
        s3 = boto3.session.Session().client(
            "s3",
            region_name=s3_cfg["region"],
            endpoint_url=s3_cfg["endpoint"],
            aws_access_key_id=s3_cfg["access_key"],
            aws_secret_access_key=s3_cfg["secret_key"],
        )

        log.info("Detached karaoke: product=%s key=%s", product_id, s3_key)

        sizes = process_karaoke(
            s3, bucket, s3_key, s3_path, is_video, extension,
            public_key=karaoke_config["unsandbox_pk"],
            secret_key=karaoke_config["unsandbox_sk"],
        )

        if not sizes:
            log.warning("Detached karaoke produced no tracks: product=%s", product_id)
            return

        # Persist metadata — parent request has long since committed by now
        engine = create_engine(db_url)
        session = SASession(bind=engine)
        try:
            product = session.get(Product, product_id)
            if product:
                track_ext = extension if is_video else "wav"
                for track_name in ("instrumentals", "vocals"):
                    product.set_file_metadata(
                        track_name, track_ext, f"{track_name}.{track_ext}"
                    )
                tmp = product.file_bytes
                tmp.update(sizes)
                product.file_bytes = tmp
                session.add(product)
                session.commit()
                product.update_s3_acls(s3, bucket)
                log.info("Detached karaoke done: product=%s inst=%dB vox=%dB",
                         product_id, sizes["instrumentals"], sizes["vocals"])
        except Exception:
            session.rollback()
            log.exception("Detached karaoke DB write failed: product=%s", product_id)
        finally:
            session.close()
            engine.dispose()

        # Mirror synchronously — a daemon thread would die at os._exit
        mirror_cfg = karaoke_config["mirror"]
        if mirror_cfg:
            dst = _make_mirror_client(
                mirror_cfg["endpoint"],
                mirror_cfg["region"],
                mirror_cfg["access_key"],
                mirror_cfg["secret_key"],
            )
            for track_name in ("instrumentals", "vocals"):
                mirror_key(
                    s3, bucket, f"{s3_path}/{track_name}",
                    dst, mirror_cfg["bucket"],
                )
    except (IOError, OSError):
        pass  # another karaoke acquired lock between pre-check and here
    except Exception:
        log.exception("Detached karaoke grandchild failed: product=%s", product_id)
    finally:
        if lock_fd:
            try:
                lock_fd.close()
                os.unlink(lockfile)
            except OSError:
                pass
        os._exit(0)


def backfill_karaoke_async(shop_id, session_factory, app_settings):
    """Backfill vocal isolation tracks for a shop's catalog.

    Forks a child process so the backfill survives uWSGI worker recycling.
    Uses fcntl.flock on a lockfile for one-per-shop guard (auto-releases on crash).
    Pool size matches the unsandbox account's concurrency limit (from validate_keys).
    """
    shop_id_str = str(shop_id)
    lockfile = f"/tmp/karaoke_backfill_{shop_id_str}.lock"

    # Capture the DB URL before forking — the engine's fd dies after fork+close
    db_url = str(session_factory().get_bind().url)

    # Quick check — if lockfile exists and is locked, skip
    try:
        check_fd = open(lockfile, "w")
        fcntl.flock(check_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        # Got lock — release it, we'll re-acquire in child
        fcntl.flock(check_fd, fcntl.LOCK_UN)
        check_fd.close()
    except (IOError, OSError):
        return  # another backfill is running for this shop

    pid = os.fork()
    if pid > 0:
        # Parent: reap the intermediate child immediately so it doesn't zombie
        os.waitpid(pid, 0)
        return

    # Intermediate child: detach fully from uWSGI, then fork again
    os.setsid()
    pid2 = os.fork()
    if pid2 > 0:
        os._exit(0)  # intermediate child exits — grandchild is orphaned to init

    # --- Grandchild process: fully detached from uWSGI ---
    # Close inherited file descriptors (uWSGI sockets, HTTP connections)
    import resource
    maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
    if maxfd == resource.RLIM_INFINITY:
        maxfd = 1024
    for fd in range(3, maxfd):
        try:
            os.close(fd)
        except OSError:
            pass

    lock_fd = None
    try:
        # Acquire file lock (guards one-per-shop)
        lock_fd = open(lockfile, "w")
        fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        lock_fd.write(str(os.getpid()))
        lock_fd.flush()

        import boto3
        from sqlalchemy import create_engine
        from sqlalchemy.orm import Session as SASession
        from ..models.product import Product, get_media_type
        from ..models.shop import Shop

        # Create a fresh engine — the parent's SQLite connection fd was closed
        # after fork (lines 195-199). Reusing session_factory would hit a dead fd.
        engine = create_engine(db_url)
        session = SASession(bind=engine)

        def _make_s3():
            # BYOB: use shop's own bucket if configured (MPS-16)
            if shop and shop.has_primary_s3:
                return boto3.session.Session().client(
                    "s3",
                    region_name=shop.primary_s3_region,
                    endpoint_url=shop.primary_s3_endpoint,
                    aws_access_key_id=shop.primary_s3_access_key,
                    aws_secret_access_key=shop.primary_s3_secret_key,
                )
            return boto3.session.Session().client(
                "s3",
                region_name=app_settings["bucket.secure_uploads.region"],
                endpoint_url=app_settings["bucket.secure_uploads.post_endpoint"],
                aws_access_key_id=app_settings["bucket.secure_uploads.access_key"],
                aws_secret_access_key=app_settings["bucket.secure_uploads.secret_key"],
            )

        try:
            # Must load shop before _make_s3 can check has_primary_s3
            shop = session.get(Shop, shop_id)
            if not shop or not shop.unsandbox_public_key or not shop.unsandbox_secret_key:
                return

            s3 = _make_s3()
            bucket = shop.primary_s3_bucket if shop.has_primary_s3 else app_settings["bucket.secure_uploads"]

            pk, sk = shop.unsandbox_public_key, shop.unsandbox_secret_key

            # Query account concurrency from unsandbox API — abort if keys are bad
            try:
                key_info = validate_keys(pk, sk)
                concurrency = max(1, int(key_info.get("concurrency", 1)))
            except Exception as e:
                log.error("Backfill aborted — unsandbox keys invalid or expired for shop %s: %s", shop.name, e)
                return
            log.info("Backfill karaoke for shop %s — %d concurrent workers (pid %d)",
                     shop.name, concurrency, os.getpid())

            # Collect work items
            work = []
            skipped_existing = 0
            products = session.query(Product).filter(Product.shop_id == shop_id).all()
            log.info("Backfill: %d total products for shop %s", len(products), shop.name)
            for product in products:
                for file_key in ("product", "preview"):
                    ext = product.extensions.get(file_key)
                    if not ext:
                        continue
                    media_type = get_media_type(ext)
                    if media_type not in ("video", "audio"):
                        continue
                    if tracks_exist(s3, bucket, product.s3_path):
                        skipped_existing += 1
                        continue
                    work.append((product.id, product.s3_path, product.title, file_key, ext, media_type))
            log.info("Backfill: %d audio/video items, %d already have tracks, %d to process",
                     len(work) + skipped_existing, skipped_existing, len(work))

            if not work:
                log.info("No products need backfill for shop %s", shop.name)
                return

            MAX_RETRIES = 3
            BACKOFF_BASE = 5  # seconds

            # Process a single product with retries (runs in pool thread)
            def _process_one(item):
                product_id, s3_path, title, file_key, ext, media_type = item
                thread_s3 = _make_s3()
                is_video = (media_type == "video")

                for attempt in range(MAX_RETRIES):
                    log.info("Backfill karaoke: %s (%s) attempt %d/%d",
                             title, file_key, attempt + 1, MAX_RETRIES)
                    try:
                        sizes = process_karaoke(
                            thread_s3, bucket,
                            f"{s3_path}/{file_key}",
                            s3_path, is_video, ext,
                            public_key=pk, secret_key=sk,
                            skip_validate=True,
                        )
                        if sizes:
                            return product_id, title, file_key, ext, media_type, sizes
                    except Exception as exc:
                        # Don't retry if the source file doesn't exist in S3
                        if "NoSuchKey" in str(type(exc).__name__) or "NoSuchKey" in str(exc):
                            log.warning("  SKIP %s (%s) — source file not in S3", title, file_key)
                            return product_id, title, file_key, ext, media_type, None
                        log.exception("Backfill attempt %d failed for %s", attempt + 1, title)

                    if attempt < MAX_RETRIES - 1:
                        import time as _time
                        wait = BACKOFF_BASE * (2 ** attempt)
                        log.info("  Retrying %s in %ds...", title, wait)
                        _time.sleep(wait)

                return product_id, title, file_key, ext, media_type, None

            # Fan out with thread pool
            with ThreadPoolExecutor(max_workers=concurrency) as pool:
                futures = {pool.submit(_process_one, item): item for item in work}
                for future in as_completed(futures):
                    try:
                        product_id, title, file_key, ext, media_type, sizes = future.result()
                    except Exception:
                        item = futures[future]
                        log.exception("Backfill worker crashed for %s", item[2])
                        continue

                    if sizes:
                        # DB write only on success — no partial state
                        product = session.get(Product, product_id)
                        if not product:
                            continue
                        track_ext = ext if media_type == "video" else "wav"
                        for track_name in ("instrumentals", "vocals"):
                            product.set_file_metadata(track_name, track_ext, f"{track_name}.{track_ext}")
                        tmp = product.file_bytes
                        tmp.update(sizes)
                        product.file_bytes = tmp
                        session.add(product)
                        session.commit()
                        product.update_s3_acls(s3, bucket)
                        log.info("  OK %s (inst=%dB, vox=%dB)", title, sizes["instrumentals"], sizes["vocals"])
                    else:
                        log.warning("  FAILED %s after %d attempts", title, MAX_RETRIES)

            log.info("Backfill complete for shop %s", shop.name)

        except Exception:
            session.rollback()
            log.exception("Background karaoke backfill failed for shop %s", shop_id_str)
        finally:
            session.close()
            engine.dispose()
    except (IOError, OSError):
        pass  # couldn't acquire lock — another backfill is running
    except Exception:
        log.exception("Backfill child process failed for shop %s", shop_id_str)
    finally:
        if lock_fd:
            try:
                lock_fd.close()
                os.unlink(lockfile)
            except OSError:
                pass
        os._exit(0)
