#!/usr/bin/env python3
"""Open a folder as a project: adopt it, remember it, build its pages.

    bin/office <folder> [--project <id|urlKey|name>] [--session NAME] [--dry]
    bin/office .                          # the folder you are in
    bin/office <folder> --new "Name"      # create the Paperclip project too
    bin/office --remote <ssh-host> <folder> --project "Name"   # the folder is on another machine

Adopting writes <folder>/.harness/ (the project file, check.sh, NOTES.md) and
nothing else. An existing file is never overwritten. With --new, Paperclip gets
a project for the folder, with the per-task worktree policy set; without it,
--project adopts a project that already exists.

A session is created on the daemon of the machine that runs this command, and
the panes run on that machine. A program cannot move your client to the new
session: TUIOS refuses that from a pane. Switch with TUIOS's session switcher,
or `tuios attach --host <machine> <session>`.

The machine is remembered, so the flag is stated once. Running the same command
on a machine that is not the recorded one is refused rather than silently
producing a second copy of the same project.
"""

import json
import os
import shlex
import socket
import subprocess
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "lib"))
import harness as h  # noqa: E402
import project as pj  # noqa: E402
import tuios  # noqa: E402


def parse(argv):
    """Read the flags, then take what is left as the folder.

    A flag's value is not a folder. The first version of this treated the value
    of --remote as the folder, which is exactly the kind of bug that only shows
    up when someone uses the flag.
    """
    opts = {"folder": ".", "folder_given": False, "project": "", "session": "",
            "dry": False, "host": "", "no_switch": False, "new": "", "remote": ""}
    positional = []
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("--project", "--session", "--host", "--new", "--remote"):
            if i + 1 < len(argv):
                opts[a[2:]] = argv[i + 1]
                i += 2
                continue
            i += 1
            continue
        if a == "--dry":
            opts["dry"] = True
        elif a == "--no-switch":
            opts["no_switch"] = True
        elif not a.startswith("-"):
            positional.append(a)
        i += 1
    if positional:
        opts["folder"] = positional[0]
        opts["folder_given"] = True
    return opts


def create_project(name, folder, dry):
    """Create a Paperclip project for a folder, with the worktree policy set.

    Adopting a folder that Paperclip has never heard of needed two steps before:
    make the project in the browser, then run this command. One command is enough.
    """
    # A folder that is a git repository gets a branch per task in its own
    # worktree. A folder that is not cannot: asking for a worktree would create
    # a project whose every run fails. Those keep the folder in place instead.
    is_repo = subprocess.run(["git", "-C", folder, "rev-parse", "--is-inside-work-tree"],
                             capture_output=True, text=True, timeout=30).stdout.strip() == "true"
    body = {
        "name": name,
        "description": "Adopted from %s by bin/office." % folder,
        "status": "backlog",
        "workspace": {
            "name": name,
            "sourceType": "git_repo" if is_repo else "local_path",
            "cwd": folder,
            "isPrimary": True,
        },
    }
    if is_repo:
        body["executionWorkspacePolicy"] = {
            "enabled": True,
            "defaultMode": "isolated_workspace",
            "allowIssueOverride": True,
            "workspaceStrategy": {"type": "git_worktree", "baseRef": "main",
                                  "branchTemplate": "{{issue.identifier}}-{{slug}}"},
        }
    else:
        h.say("  not a git repository: the project keeps the folder in place,")
        h.say("  so a task changes these files directly. No branch, no worktree.")
    if dry:
        h.say("would create Paperclip project %r for %s" % (name, folder))
        return "(dry run)"
    try:
        created = h.api("POST", "/api/companies/%s/projects" % h.COMPANY, body)
    except h.Refused as exc:
        # A project with that name may already exist; adopting it is the right
        # thing to do then, not a failure.
        try:
            for p in h.api("GET", "/api/companies/%s/projects" % h.COMPANY):
                if p.get("name") == name:
                    h.say("project: %s (already in Paperclip)" % name)
                    return p["id"]
        except h.Refused:
            pass
        raise exc
    h.say("project: %s created (%s)" % (name, created.get("id")))
    return created.get("id")


def ensure_session(session, folder):
    """Make the session exist. It cannot be shown from here, and that is fine.

    Moving a person's TUIOS client to another session is refused when the call
    comes from a pane — the daemon will not let a program change what the person
    is looking at. Creating the session and building its panes works, so the
    only step left for the person is switching to it, which TUIOS has a key for.
    """
    if session in tuios.sessions():
        h.say("session: %s (already there)" % session)
        return
    params = {"name": session, "cwd": folder, "window": False}
    out = subprocess.run(["tuios", "call", "new-session", json.dumps(params)],
                         capture_output=True, text=True, timeout=60)
    if out.returncode != 0 and "session_exists" not in (out.stderr or out.stdout):
        raise h.Refused(3, "could not create session %r: %s" % (session, (out.stderr or out.stdout).strip()[:300]))
    h.say("session: %s (created; switch to it with TUIOS's session switcher)" % session)


def scaffold(folder, name):
    """Write the files this project keeps, and only the ones that are missing.

    Anything already there is left exactly as it is: adopting a folder must never
    overwrite a person's own notes or scripts.
    """
    written = []
    harness = os.path.join(folder, ".harness")
    os.makedirs(harness, exist_ok=True)

    check = os.path.join(harness, "check.sh")
    if not os.path.exists(check):
        with open(check, "w") as f:
            f.write("""#!/bin/sh
# This project's own check, run by bin/merge before and after it puts a task's
# work on the base branch. Exit 0 means "the project still passes".
#
# It does not decide that a task is finished: an agent sets a task's status, and
# the review decides whether the change is good. This is the regression check,
# the same thing a CI pipeline would run.
#
# Leave it as it is while the project has no tests. bin/merge will say there is
# no check and let the review decide. Replace the body when tests exist:
#
#   exec go test ./...
#   exec make test
echo "this project has no check yet"
exit 0
""")
        os.chmod(check, 0o755)
        written.append(check)

    notes = os.path.join(harness, "NOTES.md")
    if not os.path.exists(notes):
        with open(notes, "w") as f:
            f.write("# %s\n\nWhat this project is, what finished looks like, and anything a person\n"
                    "picking it up next week needs to know. Notes only: tasks live in Paperclip.\n" % name)
        written.append(notes)

    return written



# Where the commands live on a machine that is driven over ssh.
REMOTE_BIN = "~/.local/share/paperclip-harness/bin"

HOSTS = {
    "nixos-desktop": "192.168.20.13", ".13": "192.168.20.13", "13": "192.168.20.13",
    "sam-4screen-desktop": "192.168.20.27", "sam-4screen-desktop-1": "192.168.20.27",
    ".27": "192.168.20.27", "27": "192.168.20.27",
    "sam-thinkpad": "192.168.20.51", ".51": "192.168.20.51", "51": "192.168.20.51",
}


def local_addresses():
    """The addresses and names that mean this machine."""
    names = {socket.gethostname(), socket.gethostname().split(".")[0]}
    try:
        out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"],
                             capture_output=True, text=True, timeout=10).stdout
        for line in out.splitlines():
            parts = line.split()
            if len(parts) >= 4:
                names.add(parts[3].split("/")[0])
    except Exception:
        pass
    return names


def resolve_host(name):
    """A host name, or the address ssh can actually reach for it."""
    if not name:
        return ""
    return HOSTS.get(name, name)


def is_this_machine(name):
    addr = resolve_host(name)
    return addr in local_addresses()


def install_wrapper(addr, remote_bin):
    """Leave a short `office` on the other machine's PATH, pointing at the copy."""
    script = ("#!/bin/sh\n"
              "# Written by office on another machine. Runs the copy it keeps here.\n"
              'exec "$HOME/.local/share/paperclip-harness/bin/office" "$@"\n')
    subprocess.run(["ssh", "-o", "BatchMode=yes", addr,
                    "mkdir -p \"$HOME/.local/bin\" && cat > \"$HOME/.local/bin/office\" && chmod +x \"$HOME/.local/bin/office\""],
                   input=script, text=True, capture_output=True, timeout=60)


def run_on_remote(host, folder, opts):
    """Adopt a folder that lives on another machine, from where you are sitting.

    The session has to be made by the daemon that will hold the panes, and the
    panes have to run on the machine that owns the folder. So the work is done
    over ssh on that machine, and only the last step — showing the session — is
    the client's own business.
    """
    addr = resolve_host(host)
    probe = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", addr, "true"],
                           capture_output=True, text=True, timeout=30)
    if probe.returncode != 0:
        raise h.Refused(3, "cannot log in to %s (%s). Set up a key first: ssh-copy-id %s"
                        % (host, addr, addr))
    # The other machine needs the commands. They are copied to one fixed place
    # there rather than assumed to be in the same folder: a machine that has
    # never seen this repository still gets a working `office`. Copying every
    # time is what keeps the two in step, and it is one small folder.
    here = os.path.dirname(os.path.realpath(__file__))
    remote_bin = REMOTE_BIN
    # rsync will not make two levels of directory, so make the parent first.
    subprocess.run(["ssh", "-o", "BatchMode=yes", addr,
                    "mkdir -p \"$HOME/.local/share/paperclip-harness\""],
                   capture_output=True, text=True, timeout=60)
    copy = subprocess.run(
        ["rsync", "-a", "--delete", "--exclude", "__pycache__", "--exclude", "*.pyc",
         here + "/", "%s:%s/" % (addr, remote_bin)],
        capture_output=True, text=True, timeout=300)
    if copy.returncode != 0:
        raise h.Refused(3, "could not copy the commands to %s: %s"
                        % (host, (copy.stderr or copy.stdout).strip()[:300]))
    install_wrapper(addr, remote_bin)
    inner = [folder, "--no-switch"]
    for flag, value in (("--project", opts["project"]), ("--session", opts["session"]), ("--new", opts["new"])):
        if value:
            inner += [flag, value]
    if opts["dry"]:
        inner.append("--dry")
    # "$HOME" must stay unquoted so the other machine's shell expands it; every
    # argument after it is quoted, so a folder with a space still arrives whole.
    cmd = 'exec "$HOME/.local/share/paperclip-harness/bin/office" ' + \
        " ".join(shlex.quote(a) for a in inner)
    h.say("on %s: %s" % (host, cmd))
    out = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", addr, cmd],
                         capture_output=True, text=True, timeout=600)
    sys.stdout.write(out.stdout)
    if out.returncode != 0:
        raise h.Refused(3, "the other machine refused: %s" % (out.stderr or out.stdout).strip()[:300])
    h.say("")
    h.say("switch to it:  tuios attach --host %s %s" % (host, opts["session"] or os.path.basename(folder)))
    return 0


USAGE = """office — open a folder as a project, and say what else can be run

THE RULE
  The folder and the session live on the same machine. If you are typing inside
  the session, on the machine that owns the folder, it is just: office <folder>
  Use --host only when the folder is on a different machine from your shell.

WHAT IT DOES
  1. reads the folder
  2. if a machine is named, checks whether that is this machine, and runs locally if so
  3. if it really is another machine, checks the login and runs the same command there
  4. adopts the Paperclip project, or creates it with --new
  5. writes what is missing in <folder>/.harness/ and never overwrites
  6. records the project in ~/.config/paperclip-harness/projects.toml
  7. creates the session on that machine's daemon, then builds eight panes

COMMANDS

  office <folder> --project "<Name>"                     adopt a project Paperclip already has
  office <folder> --new "<Name>"                         create the Paperclip project as well
  office .                                               adopt the folder you are standing in
  office <folder> --session <name>                        name the session instead of the folder
  office <folder> --project "<Name>" --host <machine>     the folder is on that machine
  office <folder> --new "<Name>" --dry                    say what would happen, write nothing

  board                         create any missing pane in this project's session
  board --folder <folder>       the same, for another project
  board --replace               close this project's panes, then build all eight again
  board --reset                 close every pane in the session, then build
  project-reset <folder>        forget the session and start this project again
  merge <task>                  rebase, check, review and merge one task (asks first)
  review <task> [--post]        run ocr over one task's diff, and record the verdict
  triage <task> [--ask next|failure]   ask Mouse-1 one question about a task

MACHINES (name, or the address ssh reaches)
  nixos-desktop        .13   192.168.20.13    hosts Paperclip, the daemon, the main session
  sam-4screen-desktop  .27   192.168.20.27    your working desktop
  sam-thinkpad         .51   192.168.20.51    the laptop

EVERY MACHINE, FROM THE TERMINAL

  On .13, a folder that is on .13:
      office ~/paseo/projects/test-alpha --project "test-alpha"

  On .27, adopting a folder that lives on .13:
      office /home/sam/paseo/projects/email_agent --project "Email Agent" --host nixos-desktop

  On .27, a folder that is on .27 (stays local, no --host):
      office ~/src/thing --new "thing"

  On .51, the same folder on .51:
      office ~/src/thing --project "thing" --host sam-thinkpad

  Then attach. Nothing may move your client from inside a pane, so the last step is yours:
      tuios attach <session>                      on this machine
      tuios attach --host nixos-desktop <session>   to a session on another machine

ON ANOTHER MACHINE
  --host copies this folder of commands to the other machine, at
      ~/.local/share/paperclip-harness/bin/
  and leaves a short ~/.local/bin/office there, so it can be run from that
  machine too. Nothing has to be installed by hand. On .27 and .51 ~/.local/bin
  is not on PATH yet, so there you call it by its full path:
      ~/.local/share/paperclip-harness/bin/office <folder>

AFTER A REBOOT
  TUIOS restores a session with a fresh shell in every pane and keeps the pane
  names. Run board once for that project: it checks what each pane is actually
  running and replaces any pane that is only a prompt.

KEYS INSIDE THE PANES
  ( )  change tab        tab  change panel      enter  open / choose
  r    re-read Paperclip  R    rebuild all panes  ?      list the keys
  h j k l z n and digits belong to TUIOS and never reach the program.

MORE
  docs/STARTUP.md         these commands, the rule, and the three rebuild levels
  docs/WORKSPACES.md      what each of the eight panes is for
  docs/CLEAN-TESTS.md     the two end-to-end tests and what they measured
"""


def main():
    argv = sys.argv[1:]
    if not argv or "-h" in argv or "--help" in argv:
        h.say(USAGE.strip())
        return 0
    opts = parse(argv)
    if not opts["folder_given"]:
        # "." is a real folder: `office . --project <name>` adopts the folder you
        # are standing in. Only a missing argument is an error.
        raise h.Refused(2, "give the folder: office <folder> --project \"<Name>\"  (office --help)")
    folder = os.path.abspath(os.path.expanduser(opts["folder"]))
    # The folder is only checked here when it is this machine's folder. A folder
    # on another machine is checked there: asking this machine about it says "no
    # folder", which is true and useless.
    wanted = opts["host"] or opts["remote"]
    if not (wanted and not is_this_machine(wanted)) and not os.path.isdir(folder):
        raise h.Refused(2, "no folder at %s" % folder)

    host = opts["host"] or pj.this_machine()
    if opts["host"] and not is_this_machine(opts["host"]):
        return run_on_remote(opts["host"], folder, opts)
    if opts["host"]:
        h.say("host:    %s (this machine)" % opts["host"])

    # An adopted project already knows its session. Only a new one needs a
    # default, and a default that names a session nobody made is worse than an
    # error, so the session is checked before anything is written.
    existing = {}
    if pj.exists(folder):
        existing = pj.load(folder)
    session = (
        opts["session"]
        or existing.get("session")
        or os.path.basename(folder)
    )
    # The session is created if it does not exist and the client is moved to it,
    # so one command takes a person from a folder to a working layout. TUIOS has
    # the verb for exactly this; asking the person to run `tuios new` first was
    # the step everyone forgot.
    if opts["remote"]:
        if is_this_machine(opts["remote"]):
            h.say("host:    %s (this machine)" % opts["remote"])
            opts["remote"] = ""
        else:
            return run_on_remote(opts["remote"], folder, opts)

    if opts["dry"]:
        if opts["new"]:
            h.say("would create a Paperclip project %r for %s" % (opts["new"], folder))
        else:
            h.say("would adopt the Paperclip project %r for %s" % (opts["project"] or "<from .harness/harness.toml>", folder))
        h.say("would write %s" % os.path.join(folder, ".harness", "harness.toml"))
        h.say("would create or reuse session %r and build eight panes there" % session)
        h.say("dry run: nothing was written")
        return 0

    if opts["host"] and opts["host"] not in (pj.this_machine(), pj.this_machine().split(".")[0]):
        # Only a note, because the panes are built by the machine that owns them.
        h.say("host: %s (build the pages there)" % host)

    if opts["new"]:
        ref = create_project(opts["new"], folder, opts["dry"])
        if not ref:
            return 1
        opts["project"] = ref
    ref = opts["project"]
    if not ref and pj.exists(folder):
        ref = pj.load(folder)["paperclip"]["project"]
    if not ref:
        raise h.Refused(
            2,
            "which Paperclip project is %s? pass --project <id|urlKey|name>" % folder,
        )

    proj = pj.resolve_project(ref)
    h.say("project: %s (%s)" % (proj["name"], proj["id"]))
    h.say("folder:  %s" % folder)
    h.say("session: %s" % session)
    h.say("machine: %s" % host)

    tasks = pj.active_tasks(proj["id"])
    watching = next((t["identifier"] for t in tasks if t["status"] not in ("done", "cancelled")),
                    tasks[0]["identifier"] if tasks else "")

    cfg = {
        "session": session,
        "paperclip": {"company": h.COMPANY, "project": proj["id"]},
        "machine": {"host": host, "folder": folder},
        "watching": watching,
    }

    if opts["dry"]:
        h.say("would write %s" % pj.path(folder))
        h.say("would build pages: shell, tree, reply, log, new")
        return 0

    pj.save(folder, cfg)
    h.say("wrote   %s" % pj.path(folder))

    if not opts["no_switch"]:
        ensure_session(session, folder)

    pj.register(cfg, proj["name"])
    h.say("recorded in %s" % pj.REGISTRY)

    written = scaffold(folder, proj["name"])
    for path in written:
        h.say("wrote   %s" % path)

    # The pages are built by this machine, from this folder, in this session.
    code = os.spawnv(
        os.P_WAIT,
        sys.executable,
        [sys.executable, os.path.join(HERE, "board"), "--folder", folder],
    )
    return code


if __name__ == "__main__":
    h.main_guard(main)
