import os
from pathlib import Path

import typer

from .domain import ModelCatalog

from .persistence import TaskStore

app = typer.Typer(no_args_is_help=True, add_completion=False)

FAKE_RESPONSES = {
    "planner": {
        "PLAN": '{"schema_version":1,"goal":"fake plan","acceptance_criteria":["tests pass"],"steps":[{"schema_version":1,"id":"P1","action":"noop","verification":"tests"}]}',
        "REVIEW": '{"schema_version":1,"verdict":"PASS","issues":[],"summary":"fake review"}',
        "FINAL_VERIFY": '{"schema_version":1,"verdict":"PASS","issues":[],"summary":"fake final"}',
    },
    "implementer": {
        "PLAN_REVIEW": '{"schema_version":1,"verdict":"PASS","issues":[],"summary":"plan looks implementable"}',
        "IMPLEMENT": '{"schema_version":1,"files_changed":[],"tests_run":[],"notes":"fake"}',
        "FIX": '{"schema_version":1,"resolved":[],"not_resolved":[],"extra_changes":[],"tests_run":[]}',
    },
}


def _check_env_file_permissions() -> None:
    """Refuse startup when the configured credential environment file is too open."""
    env_file = os.environ.get("DUAL_AGENT_ENV_FILE", "")
    if not env_file:
        return
    path = Path(env_file)
    if path.exists() and (path.stat().st_mode & 0o777) > 0o600:
        raise SystemExit(f"ERROR: {env_file} has permissions {oct(path.stat().st_mode & 0o777)}, must be 0600 or stricter.")


def _store(root: Path) -> TaskStore:
    return TaskStore(root / ".dual-agent")


def build_orchestrator(root: Path, repo: Path, fake: bool):
    """Assemble the orchestrator. Adapters come from the registry, gated on capability probes."""
    from .infra.git import GitWorkspace
    from .policy import CommandPolicy
    from .registry import build
    from .runner import CommandTestRunner
    from .services import Orchestrator

    policy = CommandPolicy.discover(repo)
    _snapshot_config(root, policy)
    logs = root / ".dual-agent" / "logs"
    network = policy.policy.allow_network
    if fake:
        planner = build("fake", responses=FAKE_RESPONSES["planner"])
        implementer = build("fake", responses=FAKE_RESPONSES["implementer"])
    else:
        names = (policy.agents.planner, policy.agents.implementer)
        planner, implementer = (
            build(name, log_root=logs, allow_network=network, **_binary(policy, name)) for name in names
        )
        for name, adapter in zip(names, (planner, implementer)):
            capability = adapter.probe()
            if not capability.compatible:
                raise typer.BadParameter(
                    f"{name} is missing required features: {', '.join(capability.missing_features)}"
                )
    def build_tuned(role: str, tuning):
        """Rebuild one adapter with the model and effort a task asked for."""
        name = policy.agents.planner if role == "planner" else policy.agents.implementer
        if fake:
            return planner if role == "planner" else implementer
        return build(name, log_root=logs, allow_network=network, **_binary(policy, name, tuning))

    runner = CommandTestRunner(policy, logs, allow_network=network)
    return Orchestrator(
        _store(root),
        planner,
        implementer,
        GitWorkspace(
            allowed_repo_roots=tuple(Path(root) for root in policy.policy.allowed_repo_roots),
            owner=policy.policy.repository_owner,
        ),
        runner,
        require_approval=policy.policy.require_approval,
        allowed_repo_roots=policy.policy.allowed_repo_roots,
        implement_workers=policy.agents.implement_workers,
        limits=policy.limits,
        discussion_rounds=policy.agents.discussion_rounds,
        adapter_factory=build_tuned,
        agents=policy.agents,
    )


def _binary(policy, name: str, tuning=None) -> dict:
    """Let the config point at an install that is not on PATH, and set model and effort."""
    path = policy.agents.binaries.get(name)
    options = {"binary": path} if path else {}
    if name.startswith("codex"):
        options["review_sandbox"] = policy.agents.review_sandbox
    if policy.agents.passthrough_env:
        options["passthrough_env"] = tuple(policy.agents.passthrough_env)
    if name.startswith("claude") and policy.agents.settings_file:
        options["settings_file"] = policy.agents.settings_file
    chosen = tuning or policy.agents.tuning.get(name)
    catalog = policy.agents.catalog.get(name, ModelCatalog())
    model = (chosen.model if chosen else "") or catalog.default_model
    effort = (chosen.effort if chosen else "") or catalog.default_effort
    if model:
        options["model"] = model
    if effort:
        options["effort"] = effort
    return options


def _snapshot_config(root: Path, policy) -> None:
    """Record the configuration actually in effect, as the design's config.yml."""
    runtime = root / ".dual-agent"
    runtime.mkdir(parents=True, exist_ok=True)
    import yaml

    (runtime / "config.yml").write_text(
        yaml.safe_dump(
            {
                "commands": policy.commands,
                "limits": policy.limits.model_dump(),
                "policy": policy.policy.model_dump(),
                "agents": policy.agents.model_dump(),
            },
            allow_unicode=True,
            sort_keys=True,
        ),
        encoding="utf-8",
    )


def _lan_ips() -> list[str]:
    """Best-effort list of local network IPv4 addresses for the startup banner."""
    import socket
    try:
        addrs = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
        return sorted({a[4][0] for a in addrs if not a[4][0].startswith("127.")})
    except Exception:
        return []


@app.command()
def init(root: Path = typer.Argument(Path.cwd())) -> None:
    """Create the runtime directory and a starter command policy."""
    runtime = root / ".dual-agent"
    runtime.mkdir(parents=True, exist_ok=True)
    config = root / ".dual-agent.yml"
    if not config.is_file():
        config.write_text(
            "version: 1\ncommands:\n  test: python -m pytest\nlimits:\n"
            "  command_timeout_s: 900\n  max_fix_cycles: 4\npolicy:\n"
            "  allow_network: false\n  allow_push: false\n",
            encoding="utf-8",
        )
    typer.echo(str(runtime))


@app.command()
def status(task_id: str, root: Path = typer.Option(Path.cwd())) -> None:
    typer.echo(_store(root).load(task_id).model_dump_json(indent=2))


@app.command("list")
def list_tasks(root: Path = typer.Option(Path.cwd())) -> None:
    directory = _store(root).root / "tasks"
    for entry in sorted(directory.iterdir()) if directory.is_dir() else []:
        task = _store(root).load(entry.name)
        typer.echo(f"{task.state.value:<12} {task.task_id} {task.goal}")


@app.command()
def run(
    goal: str,
    repo: Path = typer.Option(Path.cwd(), help="target Git repository"),
    root: Path = typer.Option(Path.cwd()),
    fake: bool = typer.Option(False, help="use deterministic fake adapters"),
) -> None:
    """Create a task and drive it to a terminal state."""
    from .domain import TaskSpec

    orchestrator = build_orchestrator(root, repo, fake)
    task = orchestrator.create_task(TaskSpec(repo_path=repo, goal=goal))
    typer.echo(f"task {task.task_id}")
    final = orchestrator.run_to_completion(task.task_id)
    typer.echo(f"{final.state.value}: {final.error or 'ok'}")
    raise typer.Exit(0 if final.state.value == "DONE" else 1)


@app.command()
def resume(
    task_id: str,
    root: Path = typer.Option(Path.cwd()),
    repo: Path = typer.Option(Path.cwd()),
    fake: bool = typer.Option(False),
) -> None:
    orchestrator = build_orchestrator(root, repo, fake)
    task = orchestrator.recover(task_id)
    typer.echo(f"{task.state.value}: {task.error or 'resumable'}")


@app.command()
def cancel(task_id: str, root: Path = typer.Option(Path.cwd()), repo: Path = typer.Option(Path.cwd())) -> None:
    orchestrator = build_orchestrator(root, repo, fake=True)
    typer.echo(orchestrator.cancel(task_id).state.value)


@app.command("recover-all")
def recover_all(root: Path = typer.Option(Path.cwd()), repo: Path = typer.Option(Path.cwd())) -> None:
    """Sweep every unfinished task after a restart. Section 10.1 of the design."""
    orchestrator = build_orchestrator(root, repo, fake=True)
    for task in orchestrator.recover_all():
        typer.echo(f"{task.state.value:<12} {task.task_id} {task.error or 'resumable'}")


@app.command()
def probe() -> None:
    """Report what the installed Codex and Claude CLIs support."""
    from .adapters.claude import ClaudeAdapter
    from .adapters.codex import CodexAdapter

    from .policy import CommandPolicy

    agents = CommandPolicy.discover(Path.cwd()).agents
    pairs = (
        ("codex", CodexAdapter(**({"binary": agents.binaries["codex"]} if "codex" in agents.binaries else {}))),
        ("claude", ClaudeAdapter(**({"binary": agents.binaries["claude"]} if "claude" in agents.binaries else {}))),
    )
    for name, adapter in pairs:
        capability = adapter.probe()
        missing = ", ".join(capability.missing_features) or "-"
        optional = ", ".join(k for k, v in capability.optional_features.items() if v) or "-"
        typer.echo(f"{name}: compatible={capability.compatible} missing={missing} optional={optional}")


@app.command()
def serve(
    root: Path = typer.Option(Path.cwd(), help="runtime root holding .dual-agent"),
    repo: Path = typer.Option(Path.cwd(), help="repository used for command discovery"),
    host: str = typer.Option("127.0.0.1", help="bind address; use --lan to bind to LAN"),
    port: int = 8765,
    fake: bool = typer.Option(False, help="use the deterministic fake adapters instead of real CLIs"),
    lan: bool = typer.Option(
        False,
        "--lan",
        help="bind to all interfaces so phones on the same network can reach the UI",
    ),
    token: str = typer.Option(
        "",
        "--token",
        envvar="DUAL_AGENT_TOKEN",
        help="bearer token required on every request; leave empty for no auth (loopback only)",
        show_envvar=True,
        show_default=False,
    ),
    tls_cert: Path = typer.Option(
        None,
        "--tls-cert",
        help="path to PEM TLS certificate file; enables HTTPS on the listening socket",
        show_default=False,
    ),
    tls_key: Path = typer.Option(
        None,
        "--tls-key",
        help="path to PEM TLS private key file; required when --tls-cert is set",
        show_default=False,
    ),
) -> None:
    """Serve the web control UI. Uses the real providers unless --fake is given.

    Local-only (default):
        dual-agent serve

    LAN access with full security (HTTPS + token):
        # Generate a self-signed certificate (once):
        openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=dual-agent'
        TOKEN=$(openssl rand -hex 16)
        dual-agent serve --lan --tls-cert cert.pem --tls-key key.pem --token "$TOKEN"
        # Then open https://<LAN-IP>:8765/#token=<token> on your phone.

    LAN access — transport-unprotected (token can be intercepted on the wire):
        dual-agent serve --lan --token $(openssl rand -hex 16)
        # NOTE: the token and session cookie travel in cleartext.  Anyone on the
        # same network segment can capture them and gain full API access.  Only
        # use this mode on a physically isolated, trusted network.

    Security notes:
        - --lan binds 0.0.0.0; restrict to a trusted private network.
        - The token controls access but does NOT provide confidentiality without TLS.
        - The token is never written to logs or URLs (use the #token= fragment or the login form).
        - The DUAL_AGENT_TOKEN environment variable is an alternative to --token.
        - Use --tls-cert + --tls-key for real transport security; alternatively,
          terminate TLS at a reverse proxy and set X-Forwarded-Proto: https.
    """
    import uvicorn

    from .advisor import Advisor
    from .api import create_app
    from .auth import ProviderAuth

    _check_env_file_permissions()

    _LOOPBACK = {"127.0.0.1", "::1", "localhost"}
    if not lan and host not in _LOOPBACK:
        typer.echo(
            f"Error: --host {host!r} exposes the service on the network without --lan. "
            "Use --lan to explicitly enable LAN access (and consider --token).",
            err=True,
        )
        raise typer.Exit(code=1)

    if tls_cert and not tls_key:
        typer.echo("Error: --tls-cert requires --tls-key.", err=True)
        raise typer.Exit(code=1)
    if tls_key and not tls_cert:
        typer.echo("Error: --tls-key requires --tls-cert.", err=True)
        raise typer.Exit(code=1)

    effective_host = "0.0.0.0" if lan else host  # noqa: S104 – explicit operator choice
    effective_token = token.strip() or None
    tls_enabled = bool(tls_cert and tls_key)
    scheme = "https" if tls_enabled else "http"

    if lan and not effective_token:
        typer.echo(
            "Error: --lan requires --token (or DUAL_AGENT_TOKEN env var). "
            "Binding to 0.0.0.0 without authentication exposes all task APIs "
            "to every device on the network.\n"
            "Generate a token:  openssl rand -hex 16\n"
            "Then re-run:       dual-agent serve --lan --token <token>",
            err=True,
        )
        raise typer.Exit(code=1)

    orchestrator = build_orchestrator(root, repo, fake)
    orchestrator.startup_recovery_scan()
    advisor = Advisor(orchestrator.store, orchestrator.planner, orchestrator.implementer, orchestrator)
    provider_auth = ProviderAuth(
        {"codex": orchestrator.planner, "claude": orchestrator.implementer},
        root / ".dual-agent" / "logs",
    )

    # Startup banner — never print the token in any URL or log line.
    typer.echo(f"{scheme}://127.0.0.1:{port}")
    if lan:
        for ip in _lan_ips():
            typer.echo(f"LAN: {scheme}://{ip}:{port}")
        if effective_token:
            typer.echo(
                "Token auth required. On first visit open the URL and append  #token=<your-token>  "
                "(hash fragment — never sent to the server), or enter the token in the login form. "
                "The browser stores a session cookie for subsequent requests. "
                "(Token not shown here to avoid terminal/log exposure.)"
            )
        if tls_enabled:
            typer.echo(
                "TLS enabled: token and session cookie are encrypted in transit."
            )
        else:
            typer.echo(
                "⚠  TRANSPORT-UNPROTECTED: this is plain HTTP. The token and session "
                "cookie travel UNENCRYPTED over the local network — anyone who can observe "
                "LAN traffic can capture them and gain full API access. "
                "Token auth does NOT provide confidentiality without TLS. "
                "Use --tls-cert + --tls-key, or terminate TLS at a reverse proxy "
                "(e.g. nginx, caddy) and set X-Forwarded-Proto: https."
            )
        typer.echo(
            "⚠  PWA/Service Worker note: browsers require HTTPS (or localhost) for Service "
            "Worker registration and 'Add to Home Screen' install prompts. Over plain HTTP on "
            "LAN the app works as a normal web page but offline caching and PWA install will "
            "be unavailable on Android Chrome and iOS Safari."
            + (" Use --tls-cert + --tls-key for full PWA support." if not tls_enabled else "")
        )
    if effective_token:
        typer.echo("Token auth: enabled (token not shown in logs or URLs)")

    uvicorn_kwargs: dict = {"host": effective_host, "port": port}
    if tls_enabled:
        uvicorn_kwargs["ssl_certfile"] = str(tls_cert)
        uvicorn_kwargs["ssl_keyfile"] = str(tls_key)

    uvicorn.run(
        create_app(orchestrator, advisor, provider_auth, token=effective_token),
        **uvicorn_kwargs,
    )


if __name__ == "__main__":
    app()



