"""Cloud OAuth (Google Drive/Contacts + Microsoft OneDrive/Contacts) + migration engine."""
import os
import io
import time
import json
import tempfile
import threading
import requests
from datetime import datetime, timezone, timedelta

from database import SessionLocal
from models import CloudAccount, CloudMigration
from security import encrypt_secret, decrypt_secret
import settings_store as settings

GOOGLE_SCOPES = ("https://www.googleapis.com/auth/drive "
                 "https://www.googleapis.com/auth/contacts "
                 "https://www.googleapis.com/auth/userinfo.email")
MS_SCOPES = "offline_access User.Read Files.ReadWrite Contacts.ReadWrite"

# Scopes for IMAP mail access (separate feature from the Drive/Contacts OAuth above).
GOOGLE_MAIL_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email"
MS_MAIL_SCOPES = "offline_access https://outlook.office365.com/IMAP.AccessAsUser.All https://graph.microsoft.com/User.Read"

# Chunk sizes (Google requires multiples of 256 KiB, OneDrive of 320 KiB)
GOOGLE_CHUNK = 8 * 256 * 1024      # 2 MiB * 4 = 8 MiB (256KiB * 32)
ONEDRIVE_CHUNK = 10 * 1024 * 1024  # 10 MiB (320KiB * 32)
SIMPLE_UPLOAD_MAX = 4 * 1024 * 1024  # <=4 MiB uses simple upload


def _cfg(key, default=""):
    return settings.get(key, default) or default


def _base():
    return _cfg("OAUTH_PUBLIC_BASE", "").rstrip("/")


def redirect_uri(provider):
    return f"{_base()}/api/oauth/{provider}/callback"


def mail_redirect_uri(provider):
    return f"{_base()}/api/oauth/mail/{provider}/callback"


def is_configured(provider):
    if provider == "google":
        return bool(_cfg("GOOGLE_CLIENT_ID") and _cfg("GOOGLE_CLIENT_SECRET"))
    return bool(_cfg("MS_CLIENT_ID") and _cfg("MS_CLIENT_SECRET"))


def mail_auth_url(provider, state):
    """Authorize URL for connecting a mailbox for IMAP migration (not Drive/Contacts)."""
    from urllib.parse import urlencode
    if provider == "google":
        q = urlencode({"client_id": _cfg("GOOGLE_CLIENT_ID"), "redirect_uri": mail_redirect_uri("google"),
                       "response_type": "code", "scope": GOOGLE_MAIL_SCOPES, "access_type": "offline",
                       "prompt": "consent", "state": state})
        return f"https://accounts.google.com/o/oauth2/v2/auth?{q}"
    tenant = _cfg("MS_TENANT", "common") or "common"
    q = urlencode({"client_id": _cfg("MS_CLIENT_ID"), "redirect_uri": mail_redirect_uri("microsoft"),
                   "response_type": "code", "scope": MS_MAIL_SCOPES, "state": state, "response_mode": "query"})
    return f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?{q}"


def mail_exchange_code(provider, code):
    if provider == "google":
        r = requests.post("https://oauth2.googleapis.com/token", data={
            "code": code, "client_id": _cfg("GOOGLE_CLIENT_ID"),
            "client_secret": _cfg("GOOGLE_CLIENT_SECRET"),
            "redirect_uri": mail_redirect_uri("google"), "grant_type": "authorization_code"}, timeout=20)
    else:
        tenant = _cfg("MS_TENANT", "common") or "common"
        r = requests.post(f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", data={
            "code": code, "client_id": _cfg("MS_CLIENT_ID"),
            "client_secret": _cfg("MS_CLIENT_SECRET"), "redirect_uri": mail_redirect_uri("microsoft"),
            "grant_type": "authorization_code", "scope": MS_MAIL_SCOPES}, timeout=20)
    r.raise_for_status()
    return r.json()


def mail_refresh(provider, refresh_token):
    """Get a fresh access token for IMAP use from a stored mail refresh_token."""
    if provider == "google":
        r = requests.post("https://oauth2.googleapis.com/token", data={
            "refresh_token": refresh_token, "client_id": _cfg("GOOGLE_CLIENT_ID"),
            "client_secret": _cfg("GOOGLE_CLIENT_SECRET"), "grant_type": "refresh_token"}, timeout=20)
    else:
        tenant = _cfg("MS_TENANT", "common") or "common"
        r = requests.post(f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", data={
            "refresh_token": refresh_token, "client_id": _cfg("MS_CLIENT_ID"),
            "client_secret": _cfg("MS_CLIENT_SECRET"), "grant_type": "refresh_token",
            "scope": MS_MAIL_SCOPES}, timeout=20)
    r.raise_for_status()
    return r.json()


def auth_url(provider, state):
    from urllib.parse import urlencode
    if provider == "google":
        q = urlencode({"client_id": _cfg("GOOGLE_CLIENT_ID"), "redirect_uri": redirect_uri("google"),
                       "response_type": "code", "scope": GOOGLE_SCOPES, "access_type": "offline",
                       "prompt": "consent", "state": state})
        return f"https://accounts.google.com/o/oauth2/v2/auth?{q}"
    tenant = _cfg("MS_TENANT", "common") or "common"
    q = urlencode({"client_id": _cfg("MS_CLIENT_ID"), "redirect_uri": redirect_uri("microsoft"),
                   "response_type": "code", "scope": MS_SCOPES, "state": state, "response_mode": "query"})
    return f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?{q}"


def exchange_code(provider, code):
    if provider == "google":
        r = requests.post("https://oauth2.googleapis.com/token", data={
            "code": code, "client_id": _cfg("GOOGLE_CLIENT_ID"),
            "client_secret": _cfg("GOOGLE_CLIENT_SECRET"),
            "redirect_uri": redirect_uri("google"), "grant_type": "authorization_code"}, timeout=20)
    else:
        tenant = _cfg("MS_TENANT", "common") or "common"
        r = requests.post(f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", data={
            "code": code, "client_id": _cfg("MS_CLIENT_ID"),
            "client_secret": _cfg("MS_CLIENT_SECRET"), "redirect_uri": redirect_uri("microsoft"),
            "grant_type": "authorization_code", "scope": MS_SCOPES}, timeout=20)
    r.raise_for_status()
    return r.json()


def _refresh(provider, refresh_token):
    if provider == "google":
        r = requests.post("https://oauth2.googleapis.com/token", data={
            "refresh_token": refresh_token, "client_id": _cfg("GOOGLE_CLIENT_ID"),
            "client_secret": _cfg("GOOGLE_CLIENT_SECRET"), "grant_type": "refresh_token"}, timeout=20)
    else:
        tenant = _cfg("MS_TENANT", "common") or "common"
        r = requests.post(f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", data={
            "refresh_token": refresh_token, "client_id": _cfg("MS_CLIENT_ID"),
            "client_secret": _cfg("MS_CLIENT_SECRET"), "grant_type": "refresh_token",
            "scope": MS_SCOPES}, timeout=20)
    r.raise_for_status()
    return r.json()


def get_account_email(provider, access_token):
    try:
        if provider == "google":
            r = requests.get("https://www.googleapis.com/oauth2/v2/userinfo",
                             headers={"Authorization": f"Bearer {access_token}"}, timeout=15)
            return r.json().get("email", "google-account")
        r = requests.get("https://graph.microsoft.com/v1.0/me",
                         headers={"Authorization": f"Bearer {access_token}"}, timeout=15)
        j = r.json()
        return j.get("mail") or j.get("userPrincipalName", "microsoft-account")
    except Exception:
        return f"{provider}-account"


def save_account(db, user_id, provider, tokens):
    access = tokens.get("access_token")
    refresh = tokens.get("refresh_token")
    expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=int(tokens.get("expires_in", 3600)))
    email = get_account_email(provider, access)
    acc = (db.query(CloudAccount)
           .filter(CloudAccount.user_id == user_id, CloudAccount.provider == provider,
                   CloudAccount.email == email).first())
    if not acc:
        acc = CloudAccount(user_id=user_id, provider=provider, email=email)
        db.add(acc)
    acc.access_token_enc = encrypt_secret(access)
    if refresh:
        acc.refresh_token_enc = encrypt_secret(refresh)
    acc.expires_at = expires
    db.commit()
    return acc


def _valid_token(db, acc):
    now = datetime.now(timezone.utc).replace(tzinfo=None)
    if acc.expires_at and acc.expires_at > now + timedelta(seconds=60):
        return decrypt_secret(acc.access_token_enc)
    if not acc.refresh_token_enc:
        return decrypt_secret(acc.access_token_enc)
    tokens = _refresh(acc.provider, decrypt_secret(acc.refresh_token_enc))
    acc.access_token_enc = encrypt_secret(tokens["access_token"])
    acc.expires_at = now + timedelta(seconds=int(tokens.get("expires_in", 3600)))
    if tokens.get("refresh_token"):
        acc.refresh_token_enc = encrypt_secret(tokens["refresh_token"])
    db.commit()
    return tokens["access_token"]


# ---------- Provider file/contact ops ----------
from urllib.parse import quote

GOOGLE_FOLDER_MIME = "application/vnd.google-apps.folder"


def _gh(tok):
    return {"Authorization": f"Bearer {tok}"}


def list_files(provider, tok):
    """Return list of {id, name, size, path, mime/url} across the FULL folder tree.
    'path' is the relative folder path (without the filename); '' means root."""
    out = []
    if provider == "google":
        items = {}
        page_token = None
        while True:
            params = {"pageSize": 1000, "q": "trashed=false",
                      "fields": "nextPageToken, files(id,name,size,mimeType,parents)"}
            if page_token:
                params["pageToken"] = page_token
            r = requests.get("https://www.googleapis.com/drive/v3/files",
                             headers=_gh(tok), params=params, timeout=60).json()
            for f in r.get("files", []):
                items[f["id"]] = f
            page_token = r.get("nextPageToken")
            if not page_token:
                break

        def path_for(f):
            parts, cur, seen = [], f, set()
            while cur:
                pid = (cur.get("parents") or [None])[0]
                parent = items.get(pid) if pid else None
                if parent and parent.get("mimeType") == GOOGLE_FOLDER_MIME and parent["id"] not in seen:
                    seen.add(parent["id"])
                    parts.append(parent["name"])
                    cur = parent
                else:
                    break
            return "/".join(reversed(parts))

        for f in items.values():
            mime = str(f.get("mimeType", ""))
            if mime == GOOGLE_FOLDER_MIME or mime.startswith("application/vnd.google-apps"):
                continue  # skip folders and native Google Docs (not binary-downloadable)
            out.append({"id": f["id"], "name": f["name"], "size": int(f.get("size", 0) or 0),
                        "mime": f.get("mimeType"), "path": path_for(f)})
    else:
        stack = [("", "root")]  # (relative folder path, item id or 'root')
        while stack:
            rel, fid = stack.pop()
            target = "root" if fid == "root" else f"items/{fid}"
            url = f"https://graph.microsoft.com/v1.0/me/drive/{target}/children?$top=200"
            while url:
                r = requests.get(url, headers=_gh(tok), timeout=60).json()
                for it in r.get("value", []):
                    name = it["name"]
                    child_rel = f"{rel}/{name}" if rel else name
                    if "folder" in it:
                        stack.append((child_rel, it["id"]))
                    elif "file" in it:
                        out.append({"id": it["id"], "name": name, "size": int(it.get("size", 0) or 0),
                                    "path": rel, "url": it.get("@microsoft.graph.downloadUrl"),
                                    "mime": (it.get("file") or {}).get("mimeType")})
                url = r.get("@odata.nextLink")
    return out


def download_to_temp(provider, tok, f):
    """Stream a file to a temp path (avoids holding large files in memory). Returns (path, size)."""
    tmp = tempfile.NamedTemporaryFile(delete=False, prefix="cloudmig_")
    size = 0
    try:
        if provider == "google":
            url = f"https://www.googleapis.com/drive/v3/files/{f['id']}?alt=media"
            resp = requests.get(url, headers=_gh(tok), stream=True, timeout=600)
        else:
            url = f.get("url")
            if not url:
                meta = requests.get(f"https://graph.microsoft.com/v1.0/me/drive/items/{f['id']}",
                                    headers=_gh(tok), timeout=30).json()
                url = meta.get("@microsoft.graph.downloadUrl")
            resp = requests.get(url, stream=True, timeout=600)
        resp.raise_for_status()
        for chunk in resp.iter_content(chunk_size=1024 * 1024):
            if chunk:
                tmp.write(chunk)
                size += len(chunk)
        resp.close()
        tmp.flush()
        tmp.close()
        return tmp.name, size
    except Exception:
        try:
            tmp.close()
            os.unlink(tmp.name)
        except Exception:
            pass
        raise


def _google_ensure_folder(tok, path, cache):
    """Create/find the nested folder path on Google Drive; returns target folder id (or None for root)."""
    if not path:
        return None
    parent, accum = None, ""
    for part in path.split("/"):
        accum = f"{accum}/{part}" if accum else part
        if accum in cache:
            parent = cache[accum]
            continue
        esc = part.replace("\\", "\\\\").replace("'", "\\'")
        q = (f"name = '{esc}' and mimeType = '{GOOGLE_FOLDER_MIME}' and trashed = false "
             f"and '{parent or 'root'}' in parents")
        r = requests.get("https://www.googleapis.com/drive/v3/files", headers=_gh(tok),
                         params={"q": q, "fields": "files(id,name)"}, timeout=30).json()
        found = r.get("files", [])
        if found:
            fid = found[0]["id"]
        else:
            meta = {"name": part, "mimeType": GOOGLE_FOLDER_MIME}
            if parent:
                meta["parents"] = [parent]
            fid = requests.post("https://www.googleapis.com/drive/v3/files", headers=_gh(tok),
                                json=meta, timeout=30).json().get("id")
        cache[accum] = fid
        parent = fid
    return parent


def _odpath(p):
    return "/".join(quote(seg) for seg in p.split("/") if seg)


def _google_resumable_upload(tok, name, parents, file_path, size, mime):
    meta = {"name": name}
    if parents:
        meta["parents"] = parents
    r = requests.post("https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable",
                      headers={**_gh(tok), "Content-Type": "application/json; charset=UTF-8",
                               "X-Upload-Content-Type": mime or "application/octet-stream",
                               "X-Upload-Content-Length": str(size)},
                      data=json.dumps(meta), timeout=60)
    r.raise_for_status()
    session_url = r.headers["Location"]
    with open(file_path, "rb") as fh:
        start = 0
        while start < size:
            chunk = fh.read(GOOGLE_CHUNK)
            end = start + len(chunk) - 1
            resp = requests.put(session_url, headers={"Content-Length": str(len(chunk)),
                                "Content-Range": f"bytes {start}-{end}/{size}"},
                                data=chunk, timeout=600)
            if resp.status_code in (200, 201):
                return
            if resp.status_code != 308:
                resp.raise_for_status()
            start = end + 1


def _onedrive_upload(tok, full_path, file_path, size, mime):
    enc = _odpath(full_path.replace(":", "_"))
    if size <= SIMPLE_UPLOAD_MAX:
        with open(file_path, "rb") as fh:
            data = fh.read()
        requests.put(f"https://graph.microsoft.com/v1.0/me/drive/root:/{enc}:/content",
                     headers={**_gh(tok), "Content-Type": mime or "application/octet-stream"},
                     data=data, timeout=600).raise_for_status()
        return
    body = {"item": {"@microsoft.graph.conflictBehavior": "replace"}}
    r = requests.post(f"https://graph.microsoft.com/v1.0/me/drive/root:/{enc}:/createUploadSession",
                      headers=_gh(tok), json=body, timeout=60)
    r.raise_for_status()
    upload_url = r.json()["uploadUrl"]
    with open(file_path, "rb") as fh:
        start = 0
        while start < size:
            chunk = fh.read(ONEDRIVE_CHUNK)
            end = start + len(chunk) - 1
            resp = requests.put(upload_url, headers={"Content-Length": str(len(chunk)),
                                "Content-Range": f"bytes {start}-{end}/{size}"},
                                data=chunk, timeout=600)
            if resp.status_code in (200, 201):
                return
            if resp.status_code != 202:
                resp.raise_for_status()
            start = end + 1


def upload_file(provider, tok, name, rel_path, file_path, size, mime, folder_cache):
    """Upload a downloaded temp file to the destination, recreating the folder path.
    Uses chunked/resumable upload for files larger than SIMPLE_UPLOAD_MAX."""
    if provider == "google":
        parent_id = _google_ensure_folder(tok, rel_path, folder_cache)
        parents = [parent_id] if parent_id else []
        if size <= SIMPLE_UPLOAD_MAX:
            with open(file_path, "rb") as fh:
                content = fh.read()
            meta = {"name": name}
            if parents:
                meta["parents"] = parents
            files = {"data": ("metadata", json.dumps(meta), "application/json"),
                     "file": (name, io.BytesIO(content), mime or "application/octet-stream")}
            requests.post("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart",
                          headers=_gh(tok), files=files, timeout=600).raise_for_status()
        else:
            _google_resumable_upload(tok, name, parents, file_path, size, mime)
    else:
        full = f"{rel_path}/{name}" if rel_path else name
        _onedrive_upload(tok, full, file_path, size, mime)


def list_contacts(provider, tok):
    out = []
    if provider == "google":
        r = requests.get("https://people.googleapis.com/v1/people/me/connections",
                         headers=_gh(tok), params={"pageSize": 500, "personFields": "names,emailAddresses,phoneNumbers"}, timeout=30).json()
        for p in r.get("connections", []):
            name = (p.get("names", [{}])[0].get("displayName") if p.get("names") else "") or "Contact"
            email = (p.get("emailAddresses", [{}])[0].get("value") if p.get("emailAddresses") else None)
            phone = (p.get("phoneNumbers", [{}])[0].get("value") if p.get("phoneNumbers") else None)
            out.append({"name": name, "email": email, "phone": phone})
    else:
        r = requests.get("https://graph.microsoft.com/v1.0/me/contacts", headers=_gh(tok), params={"$top": 500}, timeout=30).json()
        for c in r.get("value", []):
            email = (c.get("emailAddresses", [{}])[0].get("address") if c.get("emailAddresses") else None)
            phone = (c.get("mobilePhone") or (c.get("homePhones") or [None])[0])
            out.append({"name": c.get("displayName") or "Contact", "email": email, "phone": phone})
    return out


def create_contact(provider, tok, c):
    if provider == "google":
        body = {"names": [{"givenName": c["name"]}]}
        if c.get("email"):
            body["emailAddresses"] = [{"value": c["email"]}]
        if c.get("phone"):
            body["phoneNumbers"] = [{"value": c["phone"]}]
        requests.post("https://people.googleapis.com/v1/people:createContact", headers=_gh(tok), json=body, timeout=30)
    else:
        body = {"displayName": c["name"]}
        if c.get("email"):
            body["emailAddresses"] = [{"address": c["email"], "name": c["name"]}]
        if c.get("phone"):
            body["mobilePhone"] = c["phone"]
        requests.post("https://graph.microsoft.com/v1.0/me/contacts", headers=_gh(tok), json=body, timeout=30)


# ---------- Engine ----------
_threads = {}


def start_cloud_migration(mid):
    if mid in _threads and _threads[mid].is_alive():
        return False
    t = threading.Thread(target=_run_cloud, args=(mid,), daemon=True)
    _threads[mid] = t
    t.start()
    return True


def _log(db, m, msg):
    logs = (m.logs or "") + f"[{datetime.now(timezone.utc).strftime('%H:%M:%S')}] {msg}\n"
    m.logs = logs[-8000:]
    db.commit()


def _run_cloud(mid):
    db = SessionLocal()
    try:
        m = db.query(CloudMigration).filter(CloudMigration.id == mid).first()
        if not m:
            return
        m.status = "running"
        m.started_at = datetime.now(timezone.utc)
        db.commit()
        src = db.query(CloudAccount).filter(CloudAccount.id == m.source_account_id).first()
        dst = db.query(CloudAccount).filter(CloudAccount.id == m.dest_account_id).first()
        stok = _valid_token(db, src)
        dtok = _valid_token(db, dst)
        _log(db, m, f"Connected {src.provider}:{src.email} -> {dst.provider}:{dst.email} ({m.kind})")
        if m.kind == "contacts":
            items = list_contacts(src.provider, stok)
            m.total = len(items)
            db.commit()
            _log(db, m, f"Found {len(items)} contact(s)")
            for i, c in enumerate(items, 1):
                try:
                    create_contact(dst.provider, dtok, c)
                    m.migrated = i
                except Exception as e:
                    m.failed = (m.failed or 0) + 1
                    _log(db, m, f"Contact failed: {e}")
                if i % 5 == 0:
                    db.commit()
            db.commit()
        else:  # files
            files = list_files(src.provider, stok)
            m.total = len(files)
            db.commit()
            _log(db, m, f"Found {len(files)} file(s) across all folders")
            folder_cache = {}
            for i, f in enumerate(files, 1):
                tmp_path = None
                try:
                    tmp_path, sz = download_to_temp(src.provider, stok, f)
                    upload_file(dst.provider, dtok, f["name"], f.get("path", ""), tmp_path, sz,
                                f.get("mime") or "application/octet-stream", folder_cache)
                    m.migrated = i
                    m.bytes_transferred = (m.bytes_transferred or 0) + sz
                    disp = f"{f.get('path')}/{f['name']}" if f.get("path") else f["name"]
                    _log(db, m, f"Copied {disp} ({sz} bytes)")
                except Exception as e:
                    m.failed = (m.failed or 0) + 1
                    _log(db, m, f"File '{f.get('name')}' failed: {e}")
                finally:
                    if tmp_path:
                        try:
                            os.unlink(tmp_path)
                        except Exception:
                            pass
                db.commit()
        m.status = "completed"
        m.completed_at = datetime.now(timezone.utc)
        db.commit()
        _log(db, m, "Cloud migration completed")
    except Exception as e:
        m = db.query(CloudMigration).filter(CloudMigration.id == mid).first()
        if m:
            m.status = "failed"
            m.error = str(e)
            db.commit()
            _log(db, m, f"Fatal: {e}")
    finally:
        db.close()
