#!/usr/bin/env python3
"""
NoWiFiCamera SD Card Downloader + Google Drive Exporter
=========================================================
Scans your phone hotspot for the camera, pulls every recording,
alarm clip and snapshot off the SD card, then uploads to Google Drive.

Requires: Python 3.7+  — extra packages installed automatically on first run.

Usage
-----
  python download_sd.py                        # download only
  python download_sd.py --gdrive               # download + upload to Google Drive
  python download_sd.py --gdrive --no-local    # upload to Drive, skip local copy
  python download_sd.py --ip 192.168.43.100    # skip scan, use known IP
  python download_sd.py --out C:/Recordings    # custom local output folder
  python download_sd.py --method ftp           # force FTP only
  python download_sd.py --method http          # force HTTP only
  python download_sd.py --dry-run              # list files, don't download
"""

import argparse
import ftplib
import os
import socket
import subprocess
import sys
import time
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor, as_completed
from html.parser import HTMLParser
from pathlib import Path

# ── colour helpers ─────────────────────────────────────────────────────────────
try:
    import ctypes
    ctypes.windll.kernel32.SetConsoleMode(
        ctypes.windll.kernel32.GetStdHandle(-11), 7)
except Exception:
    pass

R = "\033[91m"; G = "\033[92m"; Y = "\033[93m"
B = "\033[94m"; W = "\033[97m"; DIM = "\033[2m"; RST = "\033[0m"

def ok(msg):   print(f"  {G}✓{RST} {msg}")
def err(msg):  print(f"  {R}✗{RST} {msg}")
def info(msg): print(f"  {B}→{RST} {msg}")
def warn(msg): print(f"  {Y}⚠{RST} {msg}")
def head(msg): print(f"\n{W}{msg}{RST}")

# ── auto-install packages ──────────────────────────────────────────────────────
def ensure_packages(pkgs: list):
    import importlib
    missing = []
    for pkg, import_name in pkgs:
        try:
            importlib.import_module(import_name)
        except ImportError:
            missing.append(pkg)
    if missing:
        head("Installing required packages …")
        for pkg in missing:
            info(f"Installing {pkg} …")
            subprocess.check_call(
                [sys.executable, "-m", "pip", "install", "--quiet", pkg])
        ok("All packages installed.")

# ── config ─────────────────────────────────────────────────────────────────────
ANDROID_SUBNET   = "192.168.43"
IOS_SUBNET       = "172.20.10"
SCAN_RANGE       = list(range(1, 255))
CAMERA_TCP_PORTS = [21, 80, 8080, 554]
SCAN_TIMEOUT     = 0.6
FTP_TIMEOUT      = 8
HTTP_TIMEOUT     = 8

FTP_USERS = [
    ("",      ""),
    ("admin", ""),
    ("admin", "admin"),
    ("admin", "888888"),
    ("guest", ""),
]

SD_FTP_DIRS   = ["/", "/record", "/alarm", "/snap", "/sdcard",
                 "/sdcard/record", "/sdcard/alarm", "/media"]
SD_HTTP_PATHS = ["/record/", "/alarm/", "/snap/", "/sdcard/",
                 "/sd/", "/sdcard/record/", "/sdcard/alarm/",
                 "/media/", "/files/", "/download/"]

VIDEO_EXTS = {".mp4", ".avi", ".mkv", ".mov", ".264", ".h264", ".ts"}
IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
ALL_EXTS   = VIDEO_EXTS | IMAGE_EXTS

GDRIVE_FOLDER_NAME = "NoWiFiCamera Recordings"
TOKEN_FILE         = Path(__file__).parent / "gdrive_token.json"
CREDS_FILE         = Path(__file__).parent / "credentials.json"
SCOPES             = ["https://www.googleapis.com/auth/drive.file"]


# ── Google Drive helpers ───────────────────────────────────────────────────────
def gdrive_authenticate():
    from google.oauth2.credentials import Credentials
    from google.auth.transport.requests import Request
    from google_auth_oauthlib.flow import InstalledAppFlow

    creds = None
    if TOKEN_FILE.exists():
        creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)

    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            if not CREDS_FILE.exists():
                err("credentials.json not found!")
                print(f"""
  {Y}To enable Google Drive upload:{RST}
  1. Go to https://console.cloud.google.com/
  2. Create a project → Enable Google Drive API
  3. Credentials → Create → OAuth 2.0 Client ID → Desktop App
  4. Download JSON → rename to credentials.json
  5. Place credentials.json in the same folder as this script
  6. Re-run with --gdrive
""")
                sys.exit(1)
            flow = InstalledAppFlow.from_client_secrets_file(
                str(CREDS_FILE), SCOPES)
            creds = flow.run_local_server(port=0)

        TOKEN_FILE.write_text(creds.to_json())
        ok("Google account authorised — token saved.")

    return creds


def gdrive_get_or_create_folder(service, name: str) -> str:
    """Return folder ID, creating it if it doesn't exist."""
    res = service.files().list(
        q=f"name='{name}' and mimeType='application/vnd.google-apps.folder' and trashed=false",
        fields="files(id, name)"
    ).execute()
    files = res.get("files", [])
    if files:
        return files[0]["id"]

    meta = {"name": name, "mimeType": "application/vnd.google-apps.folder"}
    folder = service.files().create(body=meta, fields="id").execute()
    ok(f"Created Google Drive folder: {name}")
    return folder["id"]


def gdrive_file_exists(service, name: str, folder_id: str) -> bool:
    res = service.files().list(
        q=f"name='{name}' and '{folder_id}' in parents and trashed=false",
        fields="files(id)"
    ).execute()
    return len(res.get("files", [])) > 0


def gdrive_upload_file(service, local_path: Path, folder_id: str,
                       dry_run: bool = False) -> bool:
    from googleapiclient.http import MediaFileUpload

    name = local_path.name
    if dry_run:
        info(f"[dry-run] Upload → Drive: {name}")
        return True

    if gdrive_file_exists(service, name, folder_id):
        print(f"  {DIM}SKIP (already in Drive): {name}{RST}")
        return True

    mime = "video/mp4" if local_path.suffix.lower() in VIDEO_EXTS else "image/jpeg"
    media = MediaFileUpload(str(local_path), mimetype=mime, resumable=True)
    meta  = {"name": name, "parents": [folder_id]}

    request = service.files().create(body=meta, media_body=media, fields="id")
    response = None
    while response is None:
        status, response = request.next_chunk()
        if status:
            pct = int(status.progress() * 100)
            print(f"\r    Uploading {name} … {pct}%", end="", flush=True)
    print(f"\r    {G}✓{RST} Uploaded: {name}          ")
    return True


# ── network scanner ────────────────────────────────────────────────────────────
def _port_open(ip: str, port: int, timeout: float) -> bool:
    try:
        with socket.create_connection((ip, port), timeout=timeout):
            return True
    except (OSError, ConnectionRefusedError):
        return False


def detect_subnet() -> str:
    for subnet in (ANDROID_SUBNET, IOS_SUBNET):
        gw = f"{subnet}.1"
        if _port_open(gw, 80, 0.5) or _port_open(gw, 21, 0.5):
            return subnet
    return ""


def scan_subnet(subnet: str) -> list:
    found = []
    info(f"Scanning {subnet}.1–254 …")
    bar_w = 40

    def probe(host):
        for port in CAMERA_TCP_PORTS:
            if _port_open(host, port, SCAN_TIMEOUT):
                return host
        return None

    hosts = [f"{subnet}.{i}" for i in SCAN_RANGE]
    done  = 0
    with ThreadPoolExecutor(max_workers=128) as ex:
        futures = {ex.submit(probe, h): h for h in hosts}
        for fut in as_completed(futures):
            done += 1
            result = fut.result()
            if result:
                found.append(result)
            pct = done / len(hosts)
            bar = "█" * int(bar_w * pct) + "░" * (bar_w - int(bar_w * pct))
            print(f"\r  [{bar}] {done}/{len(hosts)}", end="", flush=True)
    print()
    return found


# ── FTP downloader ─────────────────────────────────────────────────────────────
class FTPSession:
    def __init__(self, ip):
        self.ip   = ip
        self.ftp  = None

    def connect(self) -> bool:
        for user, pwd in FTP_USERS:
            try:
                ftp = ftplib.FTP()
                ftp.connect(self.ip, 21, timeout=FTP_TIMEOUT)
                ftp.login(user, pwd)
                ftp.set_pasv(True)
                self.ftp = ftp
                label = f"{user!r}/{pwd!r}" if user else "anonymous"
                ok(f"FTP connected — credentials: {label}")
                return True
            except ftplib.all_errors:
                pass
        return False

    def list_dir(self, path):
        entries = []
        try:
            lines = []
            self.ftp.dir(path, lines.append)
            for line in lines:
                parts = line.split()
                if len(parts) < 9:
                    continue
                is_dir = line.startswith("d")
                size   = int(parts[4]) if parts[4].isdigit() else 0
                name   = " ".join(parts[8:])
                if name in (".", ".."):
                    continue
                entries.append((name, is_dir, size))
        except ftplib.all_errors:
            pass
        return entries

    def walk(self, path="/"):
        files = []
        for name, is_dir, size in self.list_dir(path):
            full = f"{path.rstrip('/')}/{name}"
            if is_dir:
                files.extend(self.walk(full))
            else:
                ext = Path(name).suffix.lower()
                if ext in ALL_EXTS or not ext:
                    files.append((full, size))
        return files

    def download(self, remote_path, local_path, dry_run=False):
        if dry_run:
            info(f"[dry-run] {remote_path}")
            return True
        local_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            with open(local_path, "wb") as f:
                self.ftp.retrbinary(f"RETR {remote_path}", f.write)
            return True
        except ftplib.all_errors as e:
            err(f"FTP download failed: {e}")
            return False

    def close(self):
        try:
            self.ftp.quit()
        except Exception:
            pass


# ── HTTP downloader ────────────────────────────────────────────────────────────
class HrefParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.links = []

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            for attr, val in attrs:
                if attr == "href" and val:
                    self.links.append(val)


def http_list(base_url):
    try:
        req = urllib.request.Request(base_url,
              headers={"User-Agent": "NoWiFiCamera-Downloader/2.0"})
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
            html = resp.read().decode("utf-8", errors="replace")
        parser = HrefParser()
        parser.feed(html)
        links = []
        for href in parser.links:
            if href.startswith(("?", "#", "javascript")):
                continue
            if not href.startswith("http"):
                href = base_url.rstrip("/") + "/" + href.lstrip("/")
            links.append(href)
        return links
    except Exception:
        return []


def http_walk(base_url, visited=None):
    if visited is None:
        visited = set()
    if base_url in visited:
        return []
    visited.add(base_url)
    files = []
    for link in http_list(base_url):
        if link in visited:
            continue
        ext = Path(link.split("?")[0]).suffix.lower()
        if ext in ALL_EXTS:
            files.append(link)
        elif link.endswith("/") and link != base_url:
            files.extend(http_walk(link, visited))
    return files


def http_download(url, local_path, dry_run=False):
    if dry_run:
        info(f"[dry-run] {url}")
        return True
    local_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        req = urllib.request.Request(url,
              headers={"User-Agent": "NoWiFiCamera-Downloader/2.0"})
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp, \
             open(local_path, "wb") as f:
            total = int(resp.headers.get("Content-Length", 0))
            done  = 0
            while True:
                chunk = resp.read(65536)
                if not chunk:
                    break
                f.write(chunk)
                done += len(chunk)
                if total:
                    pct = done / total * 100
                    print(f"\r    {pct:5.1f}%  {done//1024} KB", end="")
            print()
        return True
    except Exception as e:
        err(f"HTTP download failed: {e}")
        return False


# ── camera discovery ───────────────────────────────────────────────────────────
def find_camera(forced_ip, forced_method):
    if forced_ip:
        ip = forced_ip
        ok(f"Using IP: {ip}")
    else:
        head("Step 1 — Detecting hotspot subnet")
        subnet = detect_subnet()
        if not subnet:
            warn("Hotspot gateway not found. Trying both subnets …")
            for sn in (ANDROID_SUBNET, IOS_SUBNET):
                candidates = scan_subnet(sn)
                if candidates:
                    subnet = sn
                    break
        if not subnet:
            err("No hotspot detected. Connect this computer to your phone hotspot and retry.")
            sys.exit(1)
        ok(f"Hotspot on subnet {subnet}.x")

        head("Step 2 — Scanning for camera")
        candidates = [c for c in scan_subnet(subnet) if c != f"{subnet}.1"]
        if not candidates:
            err("No camera found. Check it is powered and connected to the hotspot.")
            sys.exit(1)
        ip = candidates[0]
        if len(candidates) > 1:
            warn(f"Multiple devices found: {candidates} — using {ip}")
        ok(f"Camera at {ip}")

    if forced_method:
        return ip, forced_method
    if _port_open(ip, 21, 2.0):
        return ip, "ftp"
    for port in (80, 8080):
        if _port_open(ip, port, 2.0):
            return ip, "http"
    err(f"Cannot connect to {ip} on any supported port.")
    sys.exit(1)


# ── download runners ───────────────────────────────────────────────────────────
def run_ftp(ip, out_dir, dry_run, no_local):
    head("Connecting via FTP")
    sess = FTPSession(ip)
    if not sess.connect():
        err("FTP login failed with all credential combinations.")
        return []

    head("Walking SD card directories")
    all_files = []
    seen = set()
    for d in SD_FTP_DIRS:
        for f, s in sess.walk(d):
            if f not in seen:
                all_files.append((f, s))
                seen.add(f)

    if not all_files:
        warn("No media files found via FTP.")
        sess.close()
        return []

    total_mb = sum(s for _, s in all_files) / 1_048_576
    head(f"Found {len(all_files)} file(s)  ({total_mb:.1f} MB)")

    downloaded = []
    for i, (remote, size) in enumerate(all_files, 1):
        name      = Path(remote).name
        local     = out_dir / remote.lstrip("/")
        size_kb   = size // 1024 if size else 0

        if not no_local and not dry_run and local.exists() \
                and local.stat().st_size == size and size > 0:
            print(f"  {DIM}[{i}/{len(all_files)}] SKIP {name}{RST}")
            downloaded.append(local)
            continue

        print(f"  [{i}/{len(all_files)}] {name}  ({size_kb} KB)")
        if no_local and not dry_run:
            # stream to temp then upload — handled by caller
            tmp = out_dir / "_tmp" / name
            if sess.download(remote, tmp, dry_run):
                downloaded.append(tmp)
        else:
            if sess.download(remote, local, dry_run):
                downloaded.append(local)

    sess.close()
    return downloaded


def run_http(ip, out_dir, dry_run, no_local):
    head("Connecting via HTTP file browser")
    all_urls = []
    seen = set()
    for path in SD_HTTP_PATHS:
        for port in (80, 8080):
            base = f"http://{ip}:{port}{path}"
            try:
                req = urllib.request.Request(base,
                      headers={"User-Agent": "NoWiFiCamera-Downloader/2.0"})
                with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT):
                    pass
                info(f"Accessible: {base}")
                for u in http_walk(base):
                    if u not in seen:
                        all_urls.append(u)
                        seen.add(u)
            except Exception:
                pass

    if not all_urls:
        warn("No media files found via HTTP.")
        return []

    head(f"Found {len(all_urls)} file(s) via HTTP")
    downloaded = []
    for i, url in enumerate(all_urls, 1):
        name  = Path(url.split("?")[0]).name
        local = out_dir / "http_downloads" / name
        print(f"  [{i}/{len(all_urls)}] {name}")
        if http_download(url, local, dry_run):
            downloaded.append(local)

    return downloaded


# ── Google Drive uploader ──────────────────────────────────────────────────────
def upload_to_gdrive(local_files: list, dry_run: bool):
    ensure_packages([
        ("google-api-python-client", "googleapiclient"),
        ("google-auth-httplib2",     "google.auth.transport.requests"),
        ("google-auth-oauthlib",     "google_auth_oauthlib"),
    ])

    from googleapiclient.discovery import build

    head("Connecting to Google Drive")
    creds   = gdrive_authenticate()
    service = build("drive", "v3", credentials=creds)

    folder_id = gdrive_get_or_create_folder(service, GDRIVE_FOLDER_NAME)
    ok(f"Target folder: {GDRIVE_FOLDER_NAME}")

    head(f"Uploading {len(local_files)} file(s) to Google Drive")
    ok_count = fail_count = 0
    for local_path in local_files:
        if not local_path.exists():
            continue
        if gdrive_upload_file(service, local_path, folder_id, dry_run):
            ok_count += 1
        else:
            fail_count += 1

    head("Google Drive upload complete")
    ok(f"Uploaded: {ok_count}  |  Failed: {fail_count}")
    ok(f"View at: https://drive.google.com/drive/folders/{folder_id}")


# ── main ───────────────────────────────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(
        description="Download NoWiFiCamera SD card recordings and export to Google Drive.")
    parser.add_argument("--ip",       help="Camera IP (skip scan)")
    parser.add_argument("--out",      default="./recordings",
                                      help="Local output folder (default: ./recordings)")
    parser.add_argument("--method",   choices=["ftp", "http"])
    parser.add_argument("--gdrive",   action="store_true",
                                      help="Upload recordings to Google Drive")
    parser.add_argument("--no-local", action="store_true",
                                      help="Delete local copy after Drive upload")
    parser.add_argument("--dry-run",  action="store_true",
                                      help="List files without downloading")
    args = parser.parse_args()

    print(f"""
{W}╔══════════════════════════════════════════════╗
║   NoWiFiCamera SD Card Downloader v2.0       ║
║   {DIM}{'+ Google Drive Export' if args.gdrive else 'Local download mode':<30}{W} ║
╚══════════════════════════════════════════════╝{RST}""")

    if args.dry_run:
        warn("DRY RUN — files will be listed but not downloaded or uploaded")

    out_dir = Path(args.out)
    if not args.dry_run:
        out_dir.mkdir(parents=True, exist_ok=True)

    started = time.time()
    ip, method = find_camera(args.ip, args.method)
    info(f"Access method: {method.upper()}")

    if method == "ftp":
        downloaded = run_ftp(ip, out_dir, args.dry_run, args.no_local)
        if not downloaded:
            info("Trying HTTP as fallback …")
            downloaded = run_http(ip, out_dir, args.dry_run, args.no_local)
    else:
        downloaded = run_http(ip, out_dir, args.dry_run, args.no_local)

    if args.gdrive and downloaded:
        upload_to_gdrive(downloaded, args.dry_run)

        if args.no_local and not args.dry_run:
            head("Cleaning up local temp files")
            tmp_dir = out_dir / "_tmp"
            for f in downloaded:
                try:
                    f.unlink()
                except Exception:
                    pass
            try:
                tmp_dir.rmdir()
            except Exception:
                pass
            ok("Local temp files removed.")

    elapsed = time.time() - started
    print(f"\n{DIM}  Completed in {elapsed:.1f}s{RST}\n")


if __name__ == "__main__":
    main()
