"""Save and compare latest reported company KPIs; no background polling.

https://vizualstocks.com/api/tutorials/earnings-kpi-watchlist-python
Python 3.10+. Install requests. Set VIZUALSTOCKS_API_KEY in your terminal.
"""
import argparse
import csv
from datetime import datetime, timezone
import json
import math
import os
from pathlib import Path
import re

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE_URL = "https://api.vizualstocks.com/v1"
FIELDS = ["ticker", "normalizedPeriod", "fiscalYear", "fiscalQuarter", "periodEnd", "reportingCurrency",
          "metricId", "name", "valueFormat", "value", "availability"]
CHANGE_FIELDS = ["ticker", "metricId", "changeType", "previousPeriod", "currentPeriod",
                 "previousCurrency", "currentCurrency", "previousValue", "currentValue"]


def make_session():
    key = os.environ.get("VIZUALSTOCKS_API_KEY", "").strip()
    if not key: raise ValueError("Set VIZUALSTOCKS_API_KEY in this terminal first.")
    session = requests.Session()
    session.headers.update({"X-API-Key": key, "Accept": "application/json"})
    retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 502, 503, 504], allowed_methods=["GET"], respect_retry_after_header=True)
    session.mount("https://", HTTPAdapter(max_retries=retry))
    return session


def api_get(session, path, **params):
    response = session.get(BASE_URL + path, params=params, timeout=(10, 60), allow_redirects=False)
    if 300 <= response.status_code < 400: raise ValueError("Unexpected API redirect.")
    response.raise_for_status()
    return response.json()


def parse_watch(entries):
    watch = {}
    for entry in entries:
        ticker, separator, metric_text = entry.partition(":")
        ticker = ticker.strip().upper()
        metrics = [value.strip() for value in metric_text.split(",")]
        if not separator or not re.fullmatch(r"[A-Z0-9][A-Z0-9.-]{0,19}", ticker):
            raise ValueError("Use --watch TICKER:metricId,anotherMetricId.")
        if ticker in watch or len(set(metrics)) != len(metrics) or not all(re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,150}", value) for value in metrics):
            raise ValueError("Use unique tickers and distinct, exact metric IDs from discovery.")
        watch[ticker] = sorted(metrics)
    if not watch: raise ValueError("Add at least one --watch selection.")
    return dict(sorted(watch.items()))


def snapshot_rows(payload, catalog, metrics, ticker):
    if payload.get("ticker") != ticker or catalog.get("ticker") != ticker:
        raise ValueError("Unexpected company identity in the response.")
    if not re.fullmatch(r"Q[1-4]'\d{2}", payload.get("normalizedPeriod") or ""):
        raise ValueError(f"No valid latest quarterly period for {ticker}.")
    definitions = {item["metricId"]: item for item in catalog.get("metrics", [])}
    rows = []
    for metric in metrics:
        if metric not in definitions: raise ValueError(f"Unknown company KPI {ticker}:{metric}. Run --discover first.")
        value = payload.get(metric)
        if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)):
            raise ValueError(f"Invalid value for {ticker}:{metric}.")
        row = {field: payload.get(field) for field in FIELDS[:6]}
        row.update(metricId=metric, name=definitions[metric].get("name"), valueFormat=definitions[metric].get("valueFormat"),
                   value=value, availability="reported" if value is not None else "not_returned")
        rows.append(row)
    return rows


def compare_snapshots(current, previous=None):
    if current.get("schemaVersion") != 1: raise ValueError("Unsupported snapshot format.")
    expected = {(ticker, metric) for ticker, metrics in current["watch"].items() for metric in metrics}
    def index(snapshot):
        rows = snapshot.get("rows", [])
        indexed = {(row["ticker"], row["metricId"]): row for row in rows}
        if len(indexed) != len(rows) or set(indexed) != expected:
            raise ValueError("Snapshot is incomplete or contains duplicate rows.")
        return indexed
    latest = index(current)
    if previous is None: return "baseline", []
    if previous.get("schemaVersion") != 1 or previous.get("watch") != current["watch"]:
        raise ValueError("Previous snapshot must use the same ticker/metric selections. Start a new baseline for a different watchlist.")
    earlier = index(previous)
    changes = []
    for key, row in latest.items():
        old = earlier[key]
        identity = ["normalizedPeriod", "fiscalYear", "fiscalQuarter", "periodEnd"]
        if row["normalizedPeriod"] != old["normalizedPeriod"]:
            kind = "reporting_period_changed"
        elif any(row.get(field) != old.get(field) for field in identity) or row.get("reportingCurrency") != old.get("reportingCurrency") or row.get("valueFormat") != old.get("valueFormat"):
            kind = "reporting_basis_changed"
        elif row["value"] == old["value"]:
            continue
        elif row["value"] is None:
            kind = "value_no_longer_returned"
        elif old["value"] is None:
            kind = "value_became_available"
        else:
            kind = "value_updated_same_period"
        changes.append(dict(ticker=key[0], metricId=key[1], changeType=kind,
                            previousPeriod=old["normalizedPeriod"], currentPeriod=row["normalizedPeriod"],
                            previousCurrency=old.get("reportingCurrency"), currentCurrency=row.get("reportingCurrency"),
                            previousValue=old["value"], currentValue=row["value"]))
    return "changed" if changes else "unchanged", changes


def save_csv(path, fields, rows):
    # Keep API labels literal when a reader opens a CSV in Excel.
    def literal(value):
        if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")): return "'" + value
        return value
    with path.open("x", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=fields)
        writer.writeheader()
        writer.writerows({field: literal(row.get(field)) for field in fields} for row in rows)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--discover", metavar="TICKER", help="List company KPI IDs and whether they occur in the latest snapshot")
    parser.add_argument("--watch", action="append", help="TICKER:metricId,anotherMetricId; repeat for each company")
    parser.add_argument("--previous", type=Path, help="snapshot.json from your previous successful run")
    parser.add_argument("--output-dir", type=Path, default=Path("earnings-run-01"))
    args = parser.parse_args()
    try:
        if args.discover:
            ticker = args.discover.strip().upper()
            if not re.fullmatch(r"[A-Z0-9][A-Z0-9.-]{0,19}", ticker): raise ValueError("Use a valid ticker.")
            with make_session() as session:
                catalog = api_get(session, f"/companies/{ticker}/metrics/catalog", type="companyKpi", frequency="quarterly", status="all")
                payload = api_get(session, f"/companies/{ticker}/earnings/latest")
            for item in catalog.get("metrics", []):
                state = "reported" if payload.get(item["metricId"]) is not None else "not returned in latest period"
                print(f"{item['metricId']}: {item['name']} ({item.get('valueFormat')}; {state})")
            return
        watch = parse_watch(args.watch or [])
        if args.output_dir.exists(): raise ValueError("Choose a new output directory; previous runs are never overwritten.")
        previous = json.loads(args.previous.read_text(encoding="utf-8")) if args.previous else None
        rows = []
        with make_session() as session:
            for ticker, metrics in watch.items():
                catalog = api_get(session, f"/companies/{ticker}/metrics/catalog", type="companyKpi", frequency="quarterly", status="all")
                payload = api_get(session, f"/companies/{ticker}/earnings/latest")
                rows.extend(snapshot_rows(payload, catalog, metrics, ticker))
        current = dict(schemaVersion=1, retrievedAtUtc=datetime.now(timezone.utc).isoformat(), watch=watch, rows=rows)
        status, changes = compare_snapshots(current, previous)
        current.update(status=status, changedRows=len(changes))
        # Write only after every company succeeds. The baseline JSON is written last.
        args.output_dir.mkdir(parents=True, exist_ok=False)
        save_csv(args.output_dir / "watchlist.csv", FIELDS, rows)
        save_csv(args.output_dir / "changes.csv", CHANGE_FIELDS, changes)
        with (args.output_dir / "snapshot.json").open("x", encoding="utf-8") as file: json.dump(current, file, indent=2, allow_nan=False)
        print(f"{status}: {len(watch)} companies, {len(rows)} KPI rows, {len(changes)} changes. Saved {args.output_dir}.")
    except requests.HTTPError as error: parser.exit(1, f"API HTTP {error.response.status_code}. No successful baseline was written. Check access and quota.\n")
    except requests.RequestException: parser.exit(1, "API request failed after retries. No successful baseline was written.\n")
    except (ValueError, OSError, KeyError, TypeError) as error: parser.exit(1, f"{error}\n")


if __name__ == "__main__": main()
