set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
set unstable
set script-interpreter := ['uv', 'run', '--project', '.', '--script']

export PYTHONPATH := source_directory()

[private]
default:
    @just --list --list-submodules

# install the uv package manager
[linux]
[macos]
install-uv:
    curl -LsSf https://astral.sh/uv/install.sh | sh

# install the uv package manager
[windows]
install-uv:
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

# setup the venv and pre-commit hooks
setup python="python":
    uv venv -p {{ python }}
    @just install-prek

# install git pre-commit hooks
install-prek:
    uvx prek install

# update and install development dependencies
install *OPTS="--all-extras":
    uv sync {{ OPTS }}

_install-docs:
    uv sync --no-default-groups --group docs --all-extras

# run static type checking with mypy
check-types-mypy *ENV:
    @just run {{ ENV }} --no-default-groups --all-extras --group typing mypy

# run static type checking with pyright
check-types-pyright *ENV:
    @just run {{ ENV }} --no-default-groups --all-extras --group typing pyright

# run all static type checking
check-types *ENV:
    @just check-types-mypy {{ ENV }}
    @just check-types-pyright {{ ENV }}

# run all static type checking in an isolated environment
check-types-isolated *ENV:
    @just check-types-mypy {{ ENV }} --exact --isolated
    @just check-types-pyright {{ ENV }} --exact --isolated

# run package checks
check-package:
    uv pip check

# remove doc build artifacts
[script]
clean-docs:
    import shutil
    shutil.rmtree('./doc/build', ignore_errors=True)

# remove the virtual environment
clean-env:
    python -c "import shutil, pathlib; p=pathlib.Path('.venv'); shutil.rmtree(p, ignore_errors=True) if p.exists() else None"

# remove all git ignored files
clean-git-ignored:
    git clean -fdX

# remove all non-repository artifacts
clean: clean-docs clean-env clean-git-ignored

# build html documentation
build-docs-html:
    @just run --group docs --all-extras --isolated --no-default-groups --exact sphinx-build --fresh-env --builder html --doctree-dir ./doc/build/doctrees ./doc/source ./doc/build/html

# build the docs
build-docs: build-docs-html

# build docs and package at the current (or given) version
build VERSION="":
    #!/usr/bin/env bash
    set -euo pipefail
    cd "{{ justfile_directory() }}"
    version_arg="{{ VERSION }}"
    export PACKAGE_VERSION="${version_arg:-${PACKAGE_VERSION:-$(just --quiet print-version)}}"
    # load-bearing: export masks the command substitution's exit status
    [ -n "$PACKAGE_VERSION" ] || { echo "error: could not determine version" >&2; exit 1; }
    just build-docs-html
    uv build

# open the html documentation
[script]
open-docs:
    import os
    import webbrowser
    webbrowser.open(f'file://{os.getcwd()}/doc/build/html/index.html')

# build and open the documentation
docs: build-docs-html open-docs

# serve the documentation with auto-reload
docs-live:
    @just run --group docs --all-extras --isolated --no-default-groups sphinx-autobuild doc/source doc/build --open-browser --watch src --port 0 --delay 1

_link-check:
    -uv run --no-default-groups --group docs sphinx-build -b linkcheck -Q -D linkcheck_timeout=10 ./doc/source ./doc/build

# check documentation links for broken links
[script]
check-docs-links: _link-check
    import os
    import sys
    import json
    from pathlib import Path
    data = json.loads(f"[{','.join((Path(os.getcwd()) / 'doc/build/output.json').read_text().splitlines())}]")
    broken_links = [
        link for link in data
        if link["status"] not in {"working", "redirected", "unchecked", "ignored"}
    ]
    if broken_links:
        for link in broken_links:
            print(f"[{link['status']}] {link['filename']}:{link['lineno']} -> {link['uri']}", file=sys.stderr)
        sys.exit(1)

# lint the documentation
check-docs *ENV:
    @just run {{ ENV }} --no-default-groups --group docs doc8 --ignore-path ./doc/build --max-line-length 100 -q ./doc

# fetch intersphinx references for the given package
[script]
fetch-refs LIB: _install-docs
    import os
    from pathlib import Path
    import logging as _logging
    import sys
    import runpy
    from sphinx.ext.intersphinx import inspect_main
    _logging.basicConfig()
    libs = runpy.run_path(Path(os.getcwd()) / "doc/source/conf.py").get("intersphinx_mapping")
    url = libs.get("{{ LIB }}", None)
    if not url:
        sys.exit(f"Unrecognized {{ LIB }}, must be one of: {', '.join(libs.keys())}")
    if url[1] is None:
        url = f"{url[0].rstrip('/')}/objects.inv"
    else:
        url = url[1]
    raise SystemExit(inspect_main([url]))

# lint the code
check-lint *ENV:
    @just run {{ ENV }} --no-default-groups --group lint ruff check --select I
    @just run {{ ENV }} --no-default-groups --group lint ruff check

# check if the code needs formatting
check-format *ENV:
    @just run {{ ENV }} --no-default-groups --group lint ruff format --check

# check that the readme renders
check-readme *ENV:
    @just run {{ ENV }} --no-default-groups --group lint -m readme_renderer ./README.md -o /tmp/README.html

# sort the python imports
sort-imports *ENV:
    @just run {{ ENV }} --no-default-groups --group lint ruff check --fix --select I

# format the code and sort imports
format *ENV:
    @just sort-imports {{ ENV }}
    just --fmt --unstable
    @just run {{ ENV }} --no-default-groups --group lint ruff format

# format the github workflow files
format-workflows:
    npx prettier --write ".github/workflows/*.{yml,yaml}"

# sort imports and fix linting issues
lint *ENV:
    @just sort-imports {{ ENV }}
    @just run {{ ENV }} --no-default-groups --group lint ruff check --fix

# fix formatting, linting issues and import sorting
fix *ENV:
    @just lint {{ ENV }}
    @just format {{ ENV }}

# run bandit static security analysis
bandit:
    @just run --no-default-groups --group lint bandit -c pyproject.toml -r ./src -f sarif -o bandit.sarif

# run zizmor security analysis of CI
zizmor:
    cargo install --locked zizmor
    zizmor --persona auditor --format sarif .github/workflows/ > zizmor.sarif

# run all static checks
check *ENV:
    @just check-lint {{ ENV }}
    @just check-format {{ ENV }}
    @just check-types {{ ENV }}
    @just check-package
    @just check-docs {{ ENV }}
    @just check-readme {{ ENV }}

# run all checks including documentation link checking (slow)
check-all *ENV:
    @just check {{ ENV }}
    @just check-docs-links

# run all tests in an isolated environment (pass any uv run flags, e.g. -p 3.13)
test-all *ENV:
    @just run {{ ENV }} --no-default-groups --exact --all-extras --group test --isolated pytest --cov-append

# run specific tests (project venv)
test *TESTS:
    @just run --group test --no-sync pytest {{ TESTS }}

# debug a test
debug-test *TESTS:
    @just run pytest \
      -o addopts='-ra -q' \
      -s --trace --pdbcls=IPython.terminal.debugger:Pdb \
      {{ TESTS }}

# run the pre-commit checks
prek:
    uvx prek run

# erase any coverage data
coverage-erase:
    @just run --no-default-groups --group coverage coverage erase

# generate the test coverage report
coverage:
    @just run --no-default-groups --group coverage coverage combine --keep *.coverage
    @just run --no-default-groups --group coverage coverage report
    @just run --no-default-groups --group coverage coverage xml

# run the command in the virtual environment
run +ARGS:
    uv run {{ ARGS }}

# print the version: the exact tag at HEAD, else YYYY.M.D.devN
print-version:
    #!/usr/bin/env bash
    set -euo pipefail
    cd "{{ justfile_directory() }}"

    # A tagged HEAD reports its tag verbatim so the tag, the built wheel and any
    # later print-version at that commit all agree. Only version tags are
    # considered, so a stray non-version tag at HEAD cannot shadow the release
    # tag. When more than one version tag points at HEAD, the highest version
    # wins so the answer is deterministic.
    exact_tag="$(git tag --points-at HEAD --sort=-v:refname 'v[0-9]*' | head -n1)"
    if [ -n "$exact_tag" ]; then
        echo "${exact_tag#v}"
        exit 0
    fi

    date_part="$(date +%Y).$(date +%m | sed 's/^0//').$(date +%d | sed 's/^0//')"
    today_iso="$(date +%Y-%m-%d)"

    tag_today="$(
      git for-each-ref --sort=-creatordate \
        --format='%(refname:short) %(creatordate:short)' refs/tags \
      | awk -v d="$today_iso" '$2==d { print $1; exit }'
    )"

    if [ -n "${tag_today:-}" ]; then
        n_part="$(git rev-list --count "${tag_today}..HEAD")"
    else
        n_part="$(git rev-list --count --since='today 00:00' HEAD)"
    fi

    echo "${date_part}.dev${n_part}"

# validate a version tag: PEP 440 normalized and matching the checked-out commit
[script]
validate_version VERSION:
    import subprocess
    from packaging.version import Version
    raw_version = "{{ VERSION }}".lstrip("v")
    version_obj = Version(raw_version)
    assert str(version_obj) == raw_version, f"unnormalized version: {raw_version}"
    printed = subprocess.run(
        ["just", "print-version"], capture_output=True, text=True, check=True
    ).stdout.strip()
    assert printed == raw_version, (
        f"print-version reports {printed}, expected {raw_version}: "
        "is HEAD at the tagged commit?"
    )
    print(raw_version)

# CalVer-release: verify, sign a tag and push it — triggers release.yml
release: install check-all
    #!/usr/bin/env bash
    set -euo pipefail
    cd "{{ justfile_directory() }}"
    [ "$(git branch --show-current)" = "main" ] || { echo "error: release must run from main" >&2; exit 1; }
    [ -z "$(git status --porcelain)" ] || { echo "error: working tree not clean" >&2; exit 1; }
    git fetch --tags origin
    git merge-base --is-ancestor HEAD origin/main || { echo "error: HEAD is not on origin/main; push first" >&2; exit 1; }
    # same mechanism print-version uses, so the two always agree on "released"
    existing="$(git tag --points-at HEAD 'v[0-9]*' | head -n1)"
    if [ -n "$existing" ]; then
        echo "error: HEAD is already released as ${existing}" >&2
        exit 1
    fi

    base="$(date +%Y).$(date +%m | sed 's/^0//').$(date +%d | sed 's/^0//')"
    version="$base"
    serial=0
    while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
        serial=$((serial + 1))
        version="${base}.${serial}"
    done

    uv run --no-sync pytest tests/ -q
    git tag -s "v${version}" -m "${version} Release"
    git push origin "v${version}" || { git tag -d "v${version}"; exit 1; }
    echo "Released ${version} — watch it at: gh run watch"
