#!/usr/bin/env python3
"""Watch Apple's refurbished Mac page and notify ntfy about new target products."""

from __future__ import annotations

import argparse
import hashlib
import html
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from html.parser import HTMLParser
from pathlib import Path
from typing import Iterable


DEFAULT_URL = "https://www.apple.com/shop/refurbished/mac"
DEFAULT_TOPIC = ""
DEFAULT_INTERVAL_SECONDS = 300
DEFAULT_STATE_FILE = ".refurbished_watch_state.json"
DEFAULT_INVENTORY_HISTORY_FILE = ".refurbished_inventory_history.jsonl"
DEFAULT_INVENTORY_STATE_FILE = ".refurbished_inventory_state.json"
DEFAULT_POLL_HISTORY_FILE = ".refurbished_poll_history.jsonl"
DEFAULT_ENV_FILE = ".env.local"
USER_AGENT = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
    "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
)
MAC_STUDIO_MIN_RAM_GB = 64
DEFAULT_TARGET_TITLE_CONTAINS = "mac studio"
DEFAULT_HOT_INTERVAL_SECONDS = 60
DEFAULT_HOT_DURATION_SECONDS = 1800


@dataclass(frozen=True)
class Product:
    key: str
    title: str
    price: str | None = None
    url: str | None = None
    ram: str | None = None
    storage: str | None = None


@dataclass(frozen=True)
class CartResult:
    product: Product
    added: bool
    message: str


@dataclass(frozen=True)
class Criteria:
    title_contains: str = DEFAULT_TARGET_TITLE_CONTAINS
    min_ram_gb: int = MAC_STUDIO_MIN_RAM_GB
    require_refurbished: bool = True


@dataclass(frozen=True)
class CartSetupStatus:
    ok: bool
    code: str
    message: str


@dataclass(frozen=True)
class InventorySnapshot:
    fingerprint: str
    products: list[Product]
    matched_keys: list[str]
    added_keys: list[str]
    removed_keys: list[str]
    changed: bool


DEFAULT_CRITERIA = Criteria()


def load_env_file(path: str = DEFAULT_ENV_FILE) -> None:
    env_path = Path(path)
    if not env_path.exists():
        return

    with env_path.open("r", encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            key = key.strip()
            value = value.strip().strip("'\"")
            if key and key not in os.environ:
                os.environ[key] = value


class ListingParser(HTMLParser):
    """Small parser tuned for Apple's refurbished listing markup."""

    def __init__(self, base_url: str, criteria: Criteria = DEFAULT_CRITERIA) -> None:
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.criteria = criteria
        self.products: list[Product] = []
        self._stack: list[str] = []
        self._in_heading = False
        self._heading_parts: list[str] = []
        self._current_href: str | None = None
        self._pending_title: str | None = None
        self._pending_url: str | None = None
        self._price_parts: list[str] = []
        self._collect_price = False

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        self._stack.append(tag)
        attr = dict(attrs)

        if tag == "a" and attr.get("href"):
            self._current_href = urllib.parse.urljoin(self.base_url, attr["href"] or "")

        if tag in {"h2", "h3"}:
            self._in_heading = True
            self._heading_parts = []

        classes = attr.get("class", "") or ""
        if "price" in classes.lower() or "current_price" in classes.lower():
            self._collect_price = True
            self._price_parts = []

    def handle_endtag(self, tag: str) -> None:
        if tag in {"h2", "h3"} and self._in_heading:
            title = clean_text(" ".join(self._heading_parts))
            if is_target_title(title, self.criteria):
                self._pending_title = title
                self._pending_url = self._current_href
            self._in_heading = False
            self._heading_parts = []

        if self._collect_price and tag in {"span", "div", "p"}:
            price = first_price(" ".join(self._price_parts))
            if price and self._pending_title:
                self._append_pending(price)
            self._collect_price = False
            self._price_parts = []

        if tag == "a":
            self._current_href = None

        if self._stack:
            self._stack.pop()

    def handle_data(self, data: str) -> None:
        if self._in_heading:
            self._heading_parts.append(data)

        if self._collect_price:
            self._price_parts.append(data)

        if self._pending_title and not self._collect_price:
            price = first_price(data)
            if price:
                self._append_pending(price)

    def close(self) -> None:
        super().close()
        if self._pending_title:
            self._append_pending(None)

    def _append_pending(self, price: str | None) -> None:
        title = self._pending_title
        url = self._pending_url
        if not title:
            return
        key = product_key(title, price, url)
        if all(product.key != key for product in self.products):
            self.products.append(Product(key=key, title=title, price=price, url=url))
        self._pending_title = None
        self._pending_url = None


def clean_text(value: str) -> str:
    value = html.unescape(value).replace("\u2011", "-").replace("\u2013", "-")
    return re.sub(r"\s+", " ", value).strip()


def first_price(value: str) -> str | None:
    match = re.search(r"\$\s?\d[\d,]*(?:\.\d{2})?", clean_text(value))
    return match.group(0).replace("$ ", "$") if match else None


def is_target_title(title: str, criteria: Criteria = DEFAULT_CRITERIA) -> bool:
    normalized = title.lower().replace("\u2011", "-")
    storage = extract_storage(title)
    return is_target_product(normalized, extract_ram(title), storage, criteria)


def is_target_product(
    title: str,
    ram: str | None,
    storage: str | None = None,
    criteria: Criteria = DEFAULT_CRITERIA,
) -> bool:
    normalized = title.lower().replace("\u2011", "-")
    if criteria.require_refurbished and "refurbished" not in normalized:
        return False
    if criteria.title_contains.lower() not in normalized:
        return False

    ram_gb = capacity_gb(ram)
    return ram_gb is not None and ram_gb >= criteria.min_ram_gb


def product_key(title: str, price: str | None, url: str | None) -> str:
    if url:
        parsed = urllib.parse.urlparse(url)
        product_match = re.search(r"/product/([^/?#]+)", parsed.path)
        if product_match:
            return product_match.group(1)
        return parsed.path.rstrip("/") or clean_text(title).lower()
    return "|".join(part for part in (clean_text(title).lower(), price) if part)


def canonical_product_url(url: str | None) -> str | None:
    if not url:
        return None
    parsed = urllib.parse.urlparse(url)
    return urllib.parse.urlunparse(parsed._replace(query="", fragment=""))


def fetch_text(url: str, timeout: float = 30.0) -> str:
    request = urllib.request.Request(
        url,
        headers={
            "User-Agent": USER_AGENT,
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "en-US,en;q=0.9",
        },
    )
    with urllib.request.urlopen(request, timeout=timeout) as response:
        charset = response.headers.get_content_charset() or "utf-8"
        return response.read().decode(charset, errors="replace")


def parse_listing(page_html: str, base_url: str, criteria: Criteria = DEFAULT_CRITERIA) -> list[Product]:
    products = parse_embedded_tiles(page_html, base_url, criteria)
    if products:
        return products

    parser = ListingParser(base_url, criteria)
    parser.feed(page_html)
    parser.close()
    return parser.products


def parse_embedded_tiles(page_html: str, base_url: str, criteria: Criteria = DEFAULT_CRITERIA) -> list[Product]:
    products: list[Product] = []
    for tile in iter_tile_objects(page_html):
        product = product_from_tile(tile, base_url, criteria)
        if product and all(existing.key != product.key for existing in products):
            products.append(product)
    return products


def parse_inventory(page_html: str, base_url: str) -> list[Product]:
    products: list[Product] = []
    for tile in iter_tile_objects(page_html):
        product = product_summary_from_tile(tile, base_url)
        if product and all(existing.key != product.key for existing in products):
            products.append(product)
    return products


def filter_target_candidates(products: Iterable[Product], criteria: Criteria) -> list[Product]:
    candidates: list[Product] = []
    needle = criteria.title_contains.lower()
    for product in products:
        normalized = product.title.lower().replace("\u2011", "-")
        if criteria.require_refurbished and "refurbished" not in normalized:
            continue
        if needle not in normalized:
            continue
        if product.ram and not is_target_product(product.title, product.ram, product.storage, criteria):
            continue
        candidates.append(product)
    return candidates


def filter_targets(products: Iterable[Product], criteria: Criteria, timeout: float = 30.0) -> list[Product]:
    targets: list[Product] = []
    for product in filter_target_candidates(products, criteria):
        enriched = enrich_product(product, timeout=timeout)
        if is_target_product(enriched.title, enriched.ram or extract_ram(enriched.title), enriched.storage, criteria):
            targets.append(enriched)
    return targets


def iter_tile_objects(page_html: str) -> Iterable[dict]:
    for array_text in extract_json_arrays_for_key(page_html, "tiles"):
        try:
            tiles = json.loads(array_text)
        except json.JSONDecodeError:
            continue
        if isinstance(tiles, list):
            for tile in tiles:
                if isinstance(tile, dict):
                    yield tile


def extract_json_arrays_for_key(text: str, key: str) -> Iterable[str]:
    marker = f'"{key}":['
    start = 0
    while True:
        marker_index = text.find(marker, start)
        if marker_index == -1:
            return
        array_start = marker_index + len(f'"{key}":')
        array_end = find_matching_json_end(text, array_start)
        if array_end is not None:
            yield text[array_start : array_end + 1]
            start = array_end + 1
        else:
            start = array_start + 1


def find_matching_json_end(text: str, start: int) -> int | None:
    if start >= len(text) or text[start] not in "[{":
        return None
    opener = text[start]
    closer = "]" if opener == "[" else "}"
    depth = 0
    in_string = False
    escaped = False
    for index in range(start, len(text)):
        char = text[index]
        if in_string:
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == '"':
                in_string = False
            continue
        if char == '"':
            in_string = True
        elif char == opener:
            depth += 1
        elif char == closer:
            depth -= 1
            if depth == 0:
                return index
    return None


def product_from_tile(tile: dict, base_url: str, criteria: Criteria = DEFAULT_CRITERIA) -> Product | None:
    product = product_summary_from_tile(tile, base_url)
    if not product:
        return None
    if not is_target_product(product.title, product.ram or extract_ram(product.title), product.storage, criteria):
        return None
    return product


def product_summary_from_tile(tile: dict, base_url: str) -> Product | None:
    title = clean_text(str(tile.get("title") or ""))
    if not title:
        return None

    dimensions = {}
    filters = tile.get("filters")
    if isinstance(filters, dict) and isinstance(filters.get("dimensions"), dict):
        dimensions = filters["dimensions"]

    ram = normalize_capacity(dimensions.get("tsMemorySize"))
    storage = normalize_capacity(dimensions.get("dimensionCapacity"))

    url_value = tile.get("productDetailsUrl")
    url = canonical_product_url(urllib.parse.urljoin(base_url, str(url_value))) if url_value else None
    part_number = str(tile.get("partNumber") or "").strip()
    key = part_number or product_key(title, None, url)

    price = None
    price_data = tile.get("price")
    if isinstance(price_data, dict):
        current_price = price_data.get("currentPrice")
        if isinstance(current_price, dict):
            raw_amount = current_price.get("raw_amount")
            amount = current_price.get("amount")
            if raw_amount:
                price = f"${float(raw_amount):,.2f}"
            elif amount:
                price = first_price(str(amount))

    return Product(key=key, title=title, price=price, url=url, ram=ram, storage=storage)


def normalize_capacity(value: object) -> str | None:
    if not value:
        return None
    text = str(value).strip().upper()
    match = re.fullmatch(r"(\d+)\s?(GB|TB)", text, flags=re.IGNORECASE)
    return f"{match.group(1)}{match.group(2).upper()}" if match else None


def capacity_gb(value: str | None) -> int | None:
    if not value:
        return None
    match = re.fullmatch(r"(\d+)\s?(GB|TB)", value.strip(), flags=re.IGNORECASE)
    if not match:
        return None
    amount = int(match.group(1))
    return amount * 1024 if match.group(2).upper() == "TB" else amount


def enrich_product(product: Product, timeout: float = 30.0) -> Product:
    if product.ram and product.storage and product.price:
        return product
    if not product.url:
        return product
    try:
        body = clean_text(fetch_text(product.url, timeout=timeout))
    except (OSError, urllib.error.URLError, TimeoutError):
        return product

    ram = extract_ram(body)
    storage = extract_storage(body)
    price = product.price or first_price(body)
    return Product(
        key=product.key,
        title=product.title,
        price=price,
        url=product.url,
        ram=product.ram or ram,
        storage=product.storage or storage,
    )


def extract_ram(text: str) -> str | None:
    patterns = [
        r"\b(\d+)\s?GB\s+(?:unified\s+)?memory\b",
        r"\b(\d+)\s?GB\s+RAM\b",
    ]
    for pattern in patterns:
        match = re.search(pattern, text, flags=re.IGNORECASE)
        if match:
            return f"{match.group(1)}GB"
    return None


def extract_storage(text: str) -> str | None:
    match = re.search(r"\b(256GB|512GB|1TB|2TB|4TB|8TB)\s+SSD\b", text, flags=re.IGNORECASE)
    return match.group(1).upper() if match else None


def load_seen(path: Path) -> set[str]:
    if not path.exists():
        return set()
    with path.open("r", encoding="utf-8") as fh:
        payload = json.load(fh)
    if isinstance(payload, dict):
        return set(str(item) for item in payload.get("seen_keys", []))
    if isinstance(payload, list):
        return set(str(item) for item in payload)
    return set()


def save_seen(path: Path, products: Iterable[Product]) -> None:
    payload = {
        "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "seen_keys": sorted({product.key for product in products}),
    }
    tmp_path = path.with_suffix(path.suffix + ".tmp")
    with tmp_path.open("w", encoding="utf-8") as fh:
        json.dump(payload, fh, indent=2)
        fh.write("\n")
    tmp_path.replace(path)


def product_payload(product: Product) -> dict:
    return asdict(product)


def inventory_fingerprint(products: Iterable[Product]) -> str:
    payload = [
        {
            "key": product.key,
            "title": product.title,
            "price": product.price,
            "ram": product.ram,
            "storage": product.storage,
            "url": product.url,
        }
        for product in sorted(products, key=lambda item: item.key)
    ]
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def load_inventory_state(path: Path) -> dict:
    if not path.exists():
        return {}
    try:
        with path.open("r", encoding="utf-8") as fh:
            payload = json.load(fh)
    except (json.JSONDecodeError, OSError):
        return {}
    return payload if isinstance(payload, dict) else {}


def save_inventory_state(path: Path, fingerprint: str, keys: Iterable[str]) -> None:
    payload = {
        "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "fingerprint": fingerprint,
        "keys": sorted(set(keys)),
    }
    tmp_path = path.with_suffix(path.suffix + ".tmp")
    with tmp_path.open("w", encoding="utf-8") as fh:
        json.dump(payload, fh, indent=2)
        fh.write("\n")
    tmp_path.replace(path)


def record_inventory_snapshot(
    products: list[Product],
    matched_products: list[Product],
    history_path: Path,
    state_path: Path,
) -> InventorySnapshot:
    fingerprint = inventory_fingerprint(products)
    previous = load_inventory_state(state_path)
    previous_keys = set(str(key) for key in previous.get("keys", []))
    current_keys = {product.key for product in products}
    added_keys = sorted(current_keys - previous_keys)
    removed_keys = sorted(previous_keys - current_keys)
    changed = fingerprint != previous.get("fingerprint")

    if changed:
        history_path.parent.mkdir(parents=True, exist_ok=True)
        event = {
            "observed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "fingerprint": fingerprint,
            "total_products": len(products),
            "matched_keys": [product.key for product in matched_products],
            "added_keys": added_keys,
            "removed_keys": removed_keys,
            "products": [product_payload(product) for product in products],
        }
        with history_path.open("a", encoding="utf-8") as fh:
            json.dump(event, fh, sort_keys=True)
            fh.write("\n")
        save_inventory_state(state_path, fingerprint, current_keys)

    return InventorySnapshot(
        fingerprint=fingerprint,
        products=products,
        matched_keys=[product.key for product in matched_products],
        added_keys=added_keys,
        removed_keys=removed_keys,
        changed=changed,
    )


def record_poll_snapshot(
    snapshot: InventorySnapshot,
    poll_history_path: Path,
) -> None:
    if not str(poll_history_path):
        return
    poll_history_path.parent.mkdir(parents=True, exist_ok=True)
    event = {
        "observed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "changed": snapshot.changed,
        "fingerprint": snapshot.fingerprint,
        "total_products": len(snapshot.products),
        "matched_count": len(snapshot.matched_keys),
        "added_count": len(snapshot.added_keys),
        "removed_count": len(snapshot.removed_keys),
    }
    with poll_history_path.open("a", encoding="utf-8") as fh:
        json.dump(event, fh, sort_keys=True)
        fh.write("\n")


def ntfy_url(server: str, topic: str) -> str:
    return server.rstrip("/") + "/" + urllib.parse.quote(topic.strip("/"))


def notify_ntfy(product: Product, server: str, topic: str, timeout: float = 30.0) -> None:
    body = format_product(product).encode("utf-8")
    request = urllib.request.Request(
        ntfy_url(server, topic),
        data=body,
        method="POST",
        headers={
            "Title": "New refurbished Apple product",
            "Priority": "urgent",
            "Tags": "computer,rotating_light",
            "Click": product.url or DEFAULT_URL,
            "Content-Type": "text/plain; charset=utf-8",
        },
    )
    with urllib.request.urlopen(request, timeout=timeout) as response:
        response.read()


def format_product(product: Product) -> str:
    details = [product.title]
    if product.ram:
        details.append(f"RAM: {product.ram}")
    if product.storage:
        details.append(f"Storage: {product.storage}")
    if product.price:
        details.append(f"Price: {product.price}")
    if product.url:
        details.append(product.url)
    return "\n".join(details)


def add_products_to_cart(
    products: Iterable[Product],
    profile_dir: str,
    headless: bool = False,
    keep_open: bool = False,
    timeout: float = 30.0,
) -> list[CartResult]:
    try:
        from playwright.sync_api import Error as PlaywrightError
        from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
        from playwright.sync_api import sync_playwright
    except ImportError as exc:
        raise RuntimeError(
            "Cart automation requires Playwright. Install it with "
            "`python3 -m pip install playwright` and `python3 -m playwright install chromium`."
        ) from exc

    results: list[CartResult] = []
    timeout_ms = int(timeout * 1000)
    profile_path = Path(profile_dir).expanduser()
    profile_path.mkdir(parents=True, exist_ok=True)

    with sync_playwright() as playwright:
        context = playwright.chromium.launch_persistent_context(
            user_data_dir=str(profile_path),
            headless=headless,
            args=["--no-first-run"],
        )
        try:
            page = context.pages[0] if context.pages else context.new_page()
            for product in products:
                if not product.url:
                    results.append(CartResult(product, False, "Product has no URL to open."))
                    continue

                try:
                    page.goto(product.url, wait_until="domcontentloaded", timeout=timeout_ms)
                    button = page.get_by_role("button", name=re.compile(r"add to (bag|cart)", re.I))
                    if button.count() == 0:
                        button = page.locator("button:has-text('Add to Bag'), input[value*='Add to Bag']")
                    button.first.click(timeout=timeout_ms)
                    page.wait_for_load_state("networkidle", timeout=timeout_ms)
                    results.append(CartResult(product, True, "Clicked Add to Bag."))
                except (PlaywrightError, PlaywrightTimeoutError) as exc:
                    results.append(CartResult(product, False, str(exc).splitlines()[0]))
            if keep_open and not headless and any(result.added for result in results):
                page.goto("https://www.apple.com/shop/bag", wait_until="domcontentloaded", timeout=timeout_ms)
                input("Apple bag is open in the cart browser. Press Enter here when you are done.")
        finally:
            if headless or not keep_open:
                context.close()

    return results


def cart_setup_status(profile_dir: str, headless: bool = True, timeout: float = 10.0) -> CartSetupStatus:
    try:
        from playwright.sync_api import Error as PlaywrightError
        from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
        from playwright.sync_api import sync_playwright
    except ImportError:
        return CartSetupStatus(
            ok=False,
            code="playwright_missing",
            message=(
                "Playwright is not installed. Run "
                "`python3 -m pip install playwright` and `python3 -m playwright install chromium`."
            ),
        )

    profile_path = Path(profile_dir).expanduser()
    profile_path.mkdir(parents=True, exist_ok=True)
    try:
        with sync_playwright() as playwright:
            context = playwright.chromium.launch_persistent_context(
                user_data_dir=str(profile_path),
                headless=headless,
                args=["--no-first-run"],
                timeout=int(timeout * 1000),
            )
            context.close()
    except (PlaywrightError, PlaywrightTimeoutError) as exc:
        message = str(exc).splitlines()[0]
        code = "browser_missing" if "Executable doesn't exist" in str(exc) else "launch_failed"
        return CartSetupStatus(ok=False, code=code, message=message)

    return CartSetupStatus(ok=True, code="ok", message="Playwright can launch Chromium.")


def cart_setup_errors(profile_dir: str, headless: bool = True, timeout: float = 10.0) -> list[str]:
    status = cart_setup_status(profile_dir, headless=headless, timeout=timeout)
    return [] if status.ok else [status.message]


def prompt_yes_no(question: str) -> bool:
    if not sys.stdin.isatty():
        return False
    while True:
        answer = input(f"{question} [y/N] ").strip().lower()
        if answer in {"y", "yes"}:
            return True
        if answer in {"", "n", "no"}:
            return False
        print("Please answer y or n.")


def install_playwright_chromium() -> bool:
    try:
        subprocess.run(
            [sys.executable, "-m", "playwright", "install", "chromium"],
            check=True,
        )
    except (OSError, subprocess.CalledProcessError) as exc:
        print(f"Chromium install failed: {exc}", file=sys.stderr)
        return False

    return True


def report_cart_setup(args: argparse.Namespace) -> None:
    if not args.add_to_cart:
        return

    status = cart_setup_status(args.cart_profile, headless=True, timeout=min(args.timeout, 10.0))
    if status.ok:
        print(f"Cart setup OK: {status.message}")
        return

    print("Cart setup problem: add-to-cart is not ready.", file=sys.stderr)
    print(f"- {status.message}", file=sys.stderr)

    if status.code == "playwright_missing":
        print("Install Playwright first:", file=sys.stderr)
        print("  python3 -m pip install playwright", file=sys.stderr)
        print("  python3 -m playwright install chromium", file=sys.stderr)
    elif status.code == "browser_missing":
        if prompt_yes_no("Chromium for Playwright is missing. Install it now?"):
            install_playwright_chromium()
            status = cart_setup_status(args.cart_profile, headless=True, timeout=min(args.timeout, 10.0))
            if status.ok:
                print(f"Cart setup OK: {status.message}")
                return
            print("Cart setup still failed after install attempt.", file=sys.stderr)
            print(f"- {status.message}", file=sys.stderr)
        else:
            print("Install Chromium later with:", file=sys.stderr)
            print("  python3 -m playwright install chromium", file=sys.stderr)

    if prompt_yes_no("Continue with watcher and ntfy notifications only, without add-to-cart?"):
        args.add_to_cart = False
        print("Continuing with add-to-cart disabled.", file=sys.stderr)
        return

    raise SystemExit("Cart setup is required for --add-to-cart. Exiting.")


def check_once(args: argparse.Namespace) -> list[Product]:
    state_path = Path(args.state_file)
    state_exists = state_path.exists()
    page = fetch_text(args.url, timeout=args.timeout)
    criteria = Criteria(title_contains=args.target_title_contains, min_ram_gb=args.min_ram_gb)
    inventory_products = parse_inventory(page, args.url)
    products = filter_targets(inventory_products, criteria, timeout=args.timeout)
    if not inventory_products:
        products = parse_listing(page, args.url, criteria)
        inventory_products = products
    snapshot = record_inventory_snapshot(
        inventory_products,
        products,
        Path(args.inventory_history_file),
        Path(args.inventory_state_file),
    )
    poll_history_file = getattr(args, "poll_history_file", "")
    if poll_history_file:
        record_poll_snapshot(snapshot, Path(poll_history_file))
    args.last_inventory_changed = snapshot.changed
    args.last_inventory_added_count = len(snapshot.added_keys)
    args.last_inventory_removed_count = len(snapshot.removed_keys)

    seen = load_seen(state_path)
    new_products = [product for product in products if product.key not in seen]
    if args.max_products > 0:
        new_products = new_products[: args.max_products]

    if not state_exists and not args.notify_on_first_run:
        save_seen(state_path, products)
        print(f"Initialized state with {len(products)} matching products. No notifications sent.")
        return []

    enriched = [enrich_product(product, timeout=args.timeout) for product in new_products]
    if args.dry_run:
        for product in enriched:
            print(format_product(product))
            print()
    else:
        if args.add_to_cart:
            try:
                cart_results = add_products_to_cart(
                    enriched,
                    profile_dir=args.cart_profile,
                    headless=args.cart_headless,
                    keep_open=args.cart_keep_open,
                    timeout=args.timeout,
                )
                for result in cart_results:
                    status = "Added to cart" if result.added else "Cart add failed"
                    print(f"{status}: {result.product.title} ({result.message})")
            except Exception as exc:
                print(f"Cart add failed before notifications: {exc}", file=sys.stderr)

        if args.no_notify:
            for product in enriched:
                print(f"Notification skipped: {product.title}")
        else:
            for product in enriched:
                notify_ntfy(product, args.ntfy_server, args.topic, timeout=args.timeout)
                print(f"Notified: {product.title}")

    save_seen(state_path, products)
    if not enriched:
        change_note = ""
        if snapshot.changed:
            change_note = (
                f" Inventory changed: +{len(snapshot.added_keys)} "
                f"-{len(snapshot.removed_keys)}."
            )
        print(f"No new matching products. Checked {len(products)} matching products across {len(inventory_products)} inventory products.{change_note}")
    return enriched


def run_forever(args: argparse.Namespace) -> None:
    hot_until = 0.0
    while True:
        try:
            new_products = check_once(args)
            if new_products or getattr(args, "last_inventory_changed", False):
                hot_until = time.time() + args.hot_duration
        except Exception as exc:  # Keep the watcher alive after transient Apple/ntfy failures.
            print(f"Check failed: {exc}", file=sys.stderr)
        interval = args.hot_interval if time.time() < hot_until else args.interval
        print(f"Next check in {interval} seconds.")
        time.sleep(interval)


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", default=os.environ.get("APPLE_REFURB_URL", DEFAULT_URL))
    parser.add_argument("--topic", default=os.environ.get("NTFY_TOPIC", DEFAULT_TOPIC))
    parser.add_argument("--ntfy-server", default=os.environ.get("NTFY_SERVER", "https://ntfy.sh"))
    parser.add_argument("--state-file", default=os.environ.get("STATE_FILE", DEFAULT_STATE_FILE))
    parser.add_argument(
        "--inventory-history-file",
        default=os.environ.get("INVENTORY_HISTORY_FILE", DEFAULT_INVENTORY_HISTORY_FILE),
        help="JSONL file where inventory-change snapshots are recorded.",
    )
    parser.add_argument(
        "--inventory-state-file",
        default=os.environ.get("INVENTORY_STATE_FILE", DEFAULT_INVENTORY_STATE_FILE),
        help="Local state file used to detect inventory changes.",
    )
    parser.add_argument(
        "--poll-history-file",
        default=os.environ.get("POLL_HISTORY_FILE", DEFAULT_POLL_HISTORY_FILE),
        help="JSONL file where one compact record is written for each successful poll. Set empty to disable.",
    )
    parser.add_argument("--interval", type=int, default=int(os.environ.get("INTERVAL_SECONDS", DEFAULT_INTERVAL_SECONDS)))
    parser.add_argument(
        "--hot-interval",
        type=int,
        default=int(os.environ.get("HOT_INTERVAL_SECONDS", DEFAULT_HOT_INTERVAL_SECONDS)),
        help="Polling interval after any inventory change or target hit.",
    )
    parser.add_argument(
        "--hot-duration",
        type=int,
        default=int(os.environ.get("HOT_DURATION_SECONDS", DEFAULT_HOT_DURATION_SECONDS)),
        help="How long to keep hot polling active after a change or hit.",
    )
    parser.add_argument("--timeout", type=float, default=float(os.environ.get("REQUEST_TIMEOUT", "30")))
    parser.add_argument(
        "--target-title-contains",
        default=os.environ.get("TARGET_TITLE_CONTAINS", DEFAULT_TARGET_TITLE_CONTAINS),
        help="Case-insensitive text that must appear in the refurbished product title.",
    )
    parser.add_argument(
        "--min-ram-gb",
        type=int,
        default=int(os.environ.get("MIN_RAM_GB", MAC_STUDIO_MIN_RAM_GB)),
        help="Minimum RAM in GB required for a product to match.",
    )
    parser.add_argument(
        "--max-products",
        type=int,
        default=int(os.environ.get("MAX_PRODUCTS", "0")),
        help="Maximum new products to handle in one check. Defaults to 0 for no limit.",
    )
    parser.add_argument("--once", action="store_true", help="Run one check and exit.")
    parser.add_argument("--dry-run", action="store_true", help="Print new products instead of notifying ntfy.")
    parser.add_argument("--no-notify", action="store_true", help="Skip ntfy notifications after matching products are handled.")
    parser.add_argument(
        "--add-to-cart",
        action="store_true",
        default=os.environ.get("ADD_TO_CART", "").lower() in {"1", "true", "yes"},
        help="Use a local Playwright browser to add new matching products to Apple's bag before notifying.",
    )
    parser.add_argument(
        "--cart-profile",
        default=os.environ.get("CART_PROFILE", ".apple_cart_browser_profile"),
        help="Persistent browser profile directory for cart automation.",
    )
    parser.add_argument(
        "--cart-headless",
        action="store_true",
        default=os.environ.get("CART_HEADLESS", "").lower() in {"1", "true", "yes"},
        help="Run cart automation without leaving a visible browser window open.",
    )
    parser.add_argument(
        "--cart-keep-open",
        action="store_true",
        default=os.environ.get("CART_KEEP_OPEN", "").lower() in {"1", "true", "yes"},
        help="After adding products, keep the cart browser open for manual checkout until Enter is pressed.",
    )
    parser.add_argument(
        "--notify-on-first-run",
        action="store_true",
        help="Send notifications for products found before a state file exists.",
    )
    return parser.parse_args(argv)


def validate_args(args: argparse.Namespace) -> None:
    if not args.dry_run and not args.no_notify and not args.topic:
        raise SystemExit(
            "NTFY_TOPIC is required for notification runs. Set it in .env.local, "
            "export it in your shell, or pass --topic."
        )


def main(argv: list[str] | None = None) -> int:
    load_env_file(os.environ.get("ENV_FILE", DEFAULT_ENV_FILE))
    args = parse_args(argv or sys.argv[1:])
    validate_args(args)
    report_cart_setup(args)
    if args.once:
        check_once(args)
    else:
        run_forever(args)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
