#!/usr/bin/env python3
"""
Stocky API export: pull everything the Stocky v2 API will still give you, before it stops.

Read-only. Standard library only. Nothing is written to your store, and nothing leaves
your machine except the calls to stocky.shopifyapps.com.

    python3 stocky_export.py --store yourstore.myshopify.com --key YOUR_API_KEY

Your API key comes from Stocky itself: Preferences, then API.

What you get, in ./stocky-export-<store>-<date>/ :

    raw/            one JSON file per resource, exactly as the API returned it
    csv/            flat CSVs of the same data, for spreadsheets and for importers
    SUMMARY.txt     counts, date ranges, and what was checked
    GAPS.txt        the things this cannot reach, and why. Read this one.
    MANIFEST.json   machine-readable record of the run

Two traps this handles that a hand-rolled script usually does not, both confirmed
against Stocky's own published docs (stocky.shopifyapps.com/api/docs/v2.html):

 1. purchase_orders pages BACKWARDS. Its since_id means "IDs less than this",
    while suppliers, stock adjustments and tax types mean "IDs greater than this".
    A script that passes since_id=0 to purchase_orders gets nothing back and reads
    as "this store has no purchase orders".

 2. The status filter has eight values, and a plain unfiltered list is not documented
    to include every one of them. This pulls each status explicitly and merges by id,
    so drafts and archived POs cannot silently go missing.
"""

import argparse
import csv
import datetime
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_BASE = "https://stocky.shopifyapps.com/api/v2"

# Pagination direction per resource, read off the published parameter docs.
# "desc": since_id returns IDs LESS than the value  (purchase_orders)
# "asc" : since_id returns IDs GREATER than the value (everything else)
RESOURCES = {
    "purchase_orders":       {"key": "purchase_orders",       "dir": "desc", "limit": 100},
    "suppliers":             {"key": "suppliers",             "dir": "asc",  "limit": 250},
    "stock_adjustments":     {"key": "stock_adjustments",     "dir": "asc",  "limit": 250},
    "stock_adjustment_items": {"key": "stock_adjustment_items", "dir": "asc", "limit": 250},
    "tax_types":             {"key": "tax_types",             "dir": "asc",  "limit": 250},
}

# Every documented status value. Pulled explicitly, then merged by id.
PO_STATUSES = [
    "draft", "draft-archived", "draft-unarchived",
    "confirmed", "confirmed-archived", "confirmed-unarchived",
    "archived", "unarchived",
]

# Fields that exist in the API but are absent from the CSV export merchants usually
# hand to a new app. Called out in SUMMARY so the value is visible.
API_ONLY_ITEM_FIELDS = ["supplier_cost_price", "received_at", "account_code",
                        "tax_type_id", "accounting_tax_type", "asin"]


class StockyError(Exception):
    pass


class Client:
    def __init__(self, store, key, base=DEFAULT_BASE, pause=0.35, timeout=60, verbose=True):
        self.store, self.key, self.base = store, key, base.rstrip("/")
        self.pause, self.timeout, self.verbose = pause, timeout, verbose
        self.calls = 0

    def get(self, resource, params=None):
        qs = urllib.parse.urlencode({k: v for k, v in (params or {}).items() if v is not None})
        url = f"{self.base}/{resource}.json" + (f"?{qs}" if qs else "")
        req = urllib.request.Request(url, headers={
            "Store-Name": self.store,
            "Authorization": f"API KEY={self.key}",
            "Accept": "application/json",
            "User-Agent": "stocky-export/1.0",
        })
        last = None
        for attempt in range(5):
            try:
                with urllib.request.urlopen(req, timeout=self.timeout) as r:
                    self.calls += 1
                    time.sleep(self.pause)
                    return json.loads(r.read().decode("utf-8"))
            except urllib.error.HTTPError as e:
                body = ""
                try:
                    body = e.read().decode("utf-8", "replace")[:300]
                except Exception:
                    pass
                if e.code in (401, 403):
                    raise StockyError(
                        f"Stocky rejected the credentials (HTTP {e.code}). Check that "
                        f"--store is the full myshopify.com domain and that the key was "
                        f"copied whole from Stocky, Preferences, API. {body}") from None
                if e.code == 404:
                    raise StockyError(f"No such resource: {resource} (HTTP 404).") from None
                if e.code in (429, 500, 502, 503, 504):
                    wait = 2 ** attempt
                    if self.verbose:
                        print(f"    HTTP {e.code}, retrying in {wait}s", file=sys.stderr)
                    time.sleep(wait)
                    last = StockyError(f"HTTP {e.code} on {resource}: {body}")
                    continue
                raise StockyError(f"HTTP {e.code} on {resource}: {body}") from None
            except (urllib.error.URLError, TimeoutError) as e:
                wait = 2 ** attempt
                if self.verbose:
                    print(f"    network error ({e}), retrying in {wait}s", file=sys.stderr)
                time.sleep(wait)
                last = StockyError(f"network error on {resource}: {e}")
        raise last or StockyError(f"gave up on {resource}")

    def page_all(self, resource, extra=None, max_pages=2000):
        """Walk a resource to exhaustion, honouring its own pagination direction.

        The seen-id guard is deliberate. If the declared direction is ever wrong,
        the walk returns the same page forever; catching a repeat stops it and the
        caller reports a warning rather than looping or silently truncating.
        """
        spec = RESOURCES[resource]
        collection, direction, limit = spec["key"], spec["dir"], spec["limit"]
        out, seen, since_id, pages, warning = [], set(), None, 0, None
        while pages < max_pages:
            params = dict(extra or {})
            params["limit"] = limit
            if since_id is not None:
                params["since_id"] = since_id
            payload = self.get(resource, params)
            if collection not in payload:
                raise StockyError(
                    f"{resource} returned no '{collection}' key. Got: {list(payload)[:6]}")
            batch = payload[collection] or []
            pages += 1
            fresh = [r for r in batch if r.get("id") not in seen]
            if batch and not fresh:
                warning = (f"{resource}: a page repeated ids already collected, so paging "
                           f"stopped early. The declared direction ('{direction}') may be "
                           f"wrong for this store. Collected {len(out)} records.")
                break
            for r in fresh:
                seen.add(r.get("id"))
            out.extend(fresh)
            if len(batch) < limit or not fresh:
                break
            ids = [r["id"] for r in batch if isinstance(r.get("id"), int)]
            if not ids:
                break
            since_id = min(ids) if direction == "desc" else max(ids)
        return out, warning


def flatten(rec, prefix=""):
    """One level of dict-in-dict becomes dotted columns. Lists become JSON text."""
    flat = {}
    for k, v in rec.items():
        name = f"{prefix}{k}"
        if isinstance(v, dict):
            flat.update(flatten(v, f"{name}."))
        elif isinstance(v, list):
            flat[name] = json.dumps(v, ensure_ascii=False)
        else:
            flat[name] = v
    return flat


def write_csv(path, rows):
    if not rows:
        path.write_text("", encoding="utf-8") if hasattr(path, "write_text") else None
        with open(path, "w", encoding="utf-8", newline="") as f:
            f.write("")
        return 0
    cols = []
    for r in rows:
        for k in r:
            if k not in cols:
                cols.append(k)
    with open(path, "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
        w.writeheader()
        for r in rows:
            w.writerow(r)
    return len(rows)


def date_range(rows, *fields):
    vals = []
    for r in rows:
        for fld in fields:
            v = r.get(fld)
            if isinstance(v, str) and re.match(r"^\d{4}-\d{2}-\d{2}", v):
                vals.append(v[:10])
    return (min(vals), max(vals)) if vals else (None, None)


GAPS_TEXT = """\
WHAT THIS EXPORT CANNOT REACH, AND WHY
======================================
Written from Stocky's own published API docs (stocky.shopifyapps.com/api/docs/v2.html),
checked {checked}. Everything below is a limit of the API itself, not of this script.
If a tool tells you otherwise, ask it which endpoint it reads.

1. SUPPLIER FREE-TEXT NOTES.
   The /suppliers payload carries 18 fields: id, name, created_at, updated_at,
   company_name, account_number, contact_name, contact_email, address1, address2,
   city, province_code, country_name, zip, phone, phone_toll_free, fax, is_hidden.
   There is no notes field in it. So anything typed into a supplier's notes, minimum
   order quantities, discount tiers, seasonal caveats, who to actually call, is not
   in the response and no tool can pull it.
   The only route is opening each supplier in Stocky and copying the text out.

2. PURCHASE ORDER NOTES.
   Same shape. The purchase order payload has no free-text notes field either.

3. PAR LEVELS AND REORDER POINTS.
   Not a documented resource. Manual capture only.

4. STOCKTAKES.
   No API resource. Use the per-stocktake CSV buttons and the Reports dropdown
   inside Stocky while the app still opens.

5. TRANSFERS.
   No API resource. Same answer as stocktakes.

6. STOCKY CUSTOM FIELDS.
   Shopify's own Stocky FAQ states these are usable only inside Stocky.

WHAT THIS EXPORT GETS THAT A PLAIN CSV EXPORT DOES NOT
------------------------------------------------------
At the purchase-item level the API returns supplier_cost_price and received_at,
plus account_code, tax_type_id, accounting_tax_type and asin. Those are the
per-delivery cost paid and the per-item receive date. Most importers are built on
the CSV export, which does not carry them, so this is the layer that quietly goes
missing in a migration and cannot be reconstructed afterwards.

TIMING, STATED HONESTLY
-----------------------
Stocky stops working on 2026-08-31 and nothing migrates by itself. Shopify's own
transition guidance says read-only access continues for at least 90 days after that,
so the record stays readable for a while, it is the working side that stops.
Your practical switch date is roughly two weeks before the 31st, because new
purchase orders have to stop in the old system before it closes.
The API is documented as read-only and has no published guarantee of staying
responsive right up to the deadline. Pull early rather than late.
"""


def main(argv=None):
    ap = argparse.ArgumentParser(
        description="Export everything the Stocky v2 API still returns.")
    ap.add_argument("--store", required=True, help="yourstore.myshopify.com")
    ap.add_argument("--key", help="Stocky API key (Stocky, Preferences, API). "
                                  "Or set STOCKY_API_KEY.")
    ap.add_argument("--out", default=".", help="where to write the export folder")
    ap.add_argument("--base", default=DEFAULT_BASE, help=argparse.SUPPRESS)
    ap.add_argument("--pause", type=float, default=0.35, help="seconds between calls")
    ap.add_argument("--only", help="comma-separated resource subset")
    ap.add_argument("--quiet", action="store_true")
    a = ap.parse_args(argv)

    key = a.key or os.environ.get("STOCKY_API_KEY")
    if not key:
        ap.error("no API key: pass --key or set STOCKY_API_KEY")
    store = a.store.strip().replace("https://", "").replace("http://", "").strip("/")
    verbose = not a.quiet

    wanted = [r.strip() for r in a.only.split(",")] if a.only else list(RESOURCES)
    unknown = [r for r in wanted if r not in RESOURCES]
    if unknown:
        ap.error(f"unknown resource(s): {', '.join(unknown)}")

    today = datetime.date.today().isoformat()
    root = os.path.join(a.out, f"stocky-export-{store.split('.')[0]}-{today}")
    raw_dir, csv_dir = os.path.join(root, "raw"), os.path.join(root, "csv")
    os.makedirs(raw_dir, exist_ok=True)
    os.makedirs(csv_dir, exist_ok=True)

    client = Client(store, key, base=a.base, pause=a.pause, verbose=verbose)
    data, warnings, summary = {}, [], []

    def say(m):
        if verbose:
            print(m)

    say(f"Stocky export for {store}")
    say(f"Writing to {root}\n")

    for res in wanted:
        say(f"  {res} ...")
        try:
            if res == "purchase_orders":
                merged, per_status = {}, {}
                for st in PO_STATUSES:
                    rows, warn = client.page_all(res, {"status": st})
                    per_status[st] = len(rows)
                    if warn:
                        warnings.append(warn)
                    for r in rows:
                        merged.setdefault(r.get("id"), r)
                # A bare list as well, in case a store's data does not answer to any
                # status filter. Merging by id means duplicates cost nothing.
                rows, warn = client.page_all(res, {})
                if warn:
                    warnings.append(warn)
                unfiltered = len(rows)
                before = len(merged)
                for r in rows:
                    merged.setdefault(r.get("id"), r)
                extra = len(merged) - before
                records = list(merged.values())
                summary.append(f"  per status: " + ", ".join(
                    f"{k}={v}" for k, v in per_status.items() if v))
                summary.append(f"  unfiltered list returned {unfiltered}; "
                               f"{extra} of those appeared under no status filter")
            else:
                records, warn = client.page_all(res)
                if warn:
                    warnings.append(warn)
        except StockyError as e:
            print(f"    FAILED: {e}", file=sys.stderr)
            warnings.append(f"{res}: {e}")
            continue

        data[res] = records
        with open(os.path.join(raw_dir, f"{res}.json"), "w", encoding="utf-8") as f:
            json.dump({RESOURCES[res]["key"]: records}, f, indent=2, ensure_ascii=False)
        write_csv(os.path.join(csv_dir, f"{res}.csv"), [flatten(r) for r in records])
        say(f"    {len(records)} records")

    # Purchase items get their own flat file: it is the layer importers lose.
    items = []
    for po in data.get("purchase_orders", []):
        for it in po.get("purchase_items") or []:
            row = flatten(it)
            row.update({"purchase_order_id": po.get("id"),
                        "purchase_order_number": po.get("number"),
                        "supplier_name": po.get("supplier_name"),
                        "supplier_id": po.get("supplier_id"),
                        "po_currency": po.get("currency"),
                        "po_ordered_at": po.get("ordered_at"),
                        "po_archived": po.get("archived")})
            items.append(row)
    if items:
        write_csv(os.path.join(csv_dir, "purchase_items.csv"), items)
        say(f"  purchase_items (flattened) ... {len(items)} rows")

    # SUMMARY
    lines = [f"Stocky export, {store}", f"Run {datetime.datetime.now().isoformat(timespec='seconds')}",
             f"API calls made: {client.calls}", ""]
    for res in wanted:
        if res not in data:
            lines.append(f"{res}: FAILED, see warnings below")
            continue
        rows = data[res]
        lo, hi = date_range(rows, "created_at", "ordered_at", "adjusted_at")
        span = f"  ({lo} to {hi})" if lo else ""
        lines.append(f"{res}: {len(rows)} records{span}")
    lines.append("")
    lines.extend(summary)
    if items:
        lines.append("")
        lines.append(f"purchase_items: {len(items)} line items across "
                     f"{len(data.get('purchase_orders', []))} purchase orders")
        for fld in API_ONLY_ITEM_FIELDS:
            filled = sum(1 for r in items if r.get(fld) not in (None, "", "null"))
            lines.append(f"  {fld}: {filled} of {len(items)} line items carry a value "
                         f"(API only, not in the CSV export)")
    if warnings:
        lines.append("")
        lines.append("WARNINGS")
        lines.extend(f"  - {w}" for w in warnings)
    lines.append("")
    lines.append("Read GAPS.txt for what this cannot reach. That list is short and it matters.")
    with open(os.path.join(root, "SUMMARY.txt"), "w", encoding="utf-8") as f:
        f.write("\n".join(lines) + "\n")

    with open(os.path.join(root, "GAPS.txt"), "w", encoding="utf-8") as f:
        f.write(GAPS_TEXT.format(checked="2026-08-03"))

    with open(os.path.join(root, "MANIFEST.json"), "w", encoding="utf-8") as f:
        json.dump({"store": store, "run_at": datetime.datetime.now().isoformat(),
                   "api_base": a.base, "api_calls": client.calls,
                   "counts": {k: len(v) for k, v in data.items()},
                   "purchase_items": len(items), "warnings": warnings,
                   "tool": "stocky_export.py 1.0"}, f, indent=2)

    say("")
    say("\n".join(lines))
    return 1 if warnings and not data else 0


if __name__ == "__main__":
    sys.exit(main())
