"""PLT-branded local web face (V-5): browser is the face, Python is the brains.

Built on the Python standard library only (no new dependencies), bound to 127.0.0.1.
The page accepts a pasted draft, an uploaded file, or a local folder path, and drives a
scan (the default, flag-first action) or an opt-in sanitize entirely server-side. Scan
and sanitize share the same source priority: an uploaded file, then a folder, then the
pasted text. No detection logic and no model inference runs in the browser; the only
client-side script is a download helper for the sanitized safe copy.

Every request is handled inside a guard that renders a readable error panel instead of
letting an exception close the connection with no body (which the browser shows as a
blank page).

The model pass is reached over the local llama-server when one is running (the web request
never launches a multi-GB model itself; `serve()` pre-warms it at startup). This module
binds a local listening socket but makes no outbound connection, so the no-egress invariant
for scan_path/sanitize_path (R-10) is unaffected; the only model call is to 127.0.0.1.
"""
from __future__ import annotations

import html
import os
import random
import sys
import tempfile
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs

from .models import ExtractedDoc, Report, Severity, TextUnit

_SEV_CLASS = {
    Severity.CRITICAL: "crit",
    Severity.HIGH: "high",
    Severity.MEDIUM: "med",
    Severity.LOW: "low",
}

# Practical Legal Tech palette (PLT-REBUILD-BRIEF): ink, brass, teal, parchment.
_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PrivSweep - Practical Legal Tech</title>
<style>
  :root {{
    --ink:#14181e; --brass:#c8a04a; --teal:#34c7ab; --parchment:#e8e2d4;
    --crit:#b00020; --high:#c75300; --med:#9a7d00; --low:#5a5f66;
  }}
  * {{ box-sizing: border-box; }}
  body {{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
         margin: 0; background: var(--parchment); color: var(--ink); }}
  header {{ background: var(--ink); color: #fff; padding: 22px 24px;
           border-bottom: 3px solid var(--brass); }}
  .brand {{ color: var(--brass); font-size: 12px; letter-spacing: 2px;
           text-transform: uppercase; font-weight: 700; }}
  header h1 {{ margin: 2px 0 0; font-size: 26px; }}
  header p {{ margin: 6px 0 0; font-size: 13px; color: var(--teal); }}
  main {{ max-width: 960px; margin: 24px auto; padding: 0 16px; }}
  form {{ background: #fff; border: 1px solid #d7d2c4; border-radius: 10px; padding: 18px; }}
  label {{ font-size: 13px; font-weight: 600; display: block; margin-bottom: 4px; }}
  .field {{ margin-bottom: 14px; }}
  textarea {{ width: 100%; min-height: 170px; font-family: ui-monospace, monospace;
             font-size: 13px; padding: 10px; border: 1px solid #ccc6b6; border-radius: 6px; }}
  input[type=text], input[type=file], select {{ font-size: 14px; padding: 8px 10px;
             border-radius: 6px; border: 1px solid #ccc6b6; background: #fff; }}
  input[type=text] {{ width: 100%; }}
  .row {{ display: flex; gap: 16px; align-items: end; flex-wrap: wrap; }}
  .actions {{ margin-top: 8px; display: flex; gap: 12px; align-items: center; }}
  button {{ font-size: 15px; padding: 10px 18px; border-radius: 6px; cursor: pointer;
           border: none; font-weight: 700; }}
  .btn-scan {{ background: var(--teal); color: var(--ink); }}
  .btn-san {{ background: #fff; color: var(--ink); border: 1px solid var(--brass); }}
  .hint {{ font-size: 12px; color: #6b6256; margin-top: 2px; }}
  .results {{ background: #fff; border: 1px solid #d7d2c4; border-radius: 10px;
             padding: 18px; margin-top: 20px; }}
  .results h2 {{ margin-top: 0; }}
  table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
  th, td {{ text-align: left; padding: 7px 8px; border-bottom: 1px solid #ece7da; }}
  th {{ color: #6b6256; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }}
  .sev {{ font-weight: 700; }}
  .sev.crit {{ color: var(--crit); }} .sev.high {{ color: var(--high); }}
  .sev.med {{ color: var(--med); }} .sev.low {{ color: var(--low); }}
  .gate-fail {{ color: var(--crit); font-weight: 700; }}
  .gate-pass {{ color: #1a7f37; font-weight: 700; }}
  pre {{ background: #f3efe4; padding: 12px; border-radius: 6px; overflow:auto; font-size: 13px; }}
  .note {{ font-size: 12px; color: #6b6256; }}
  footer {{ max-width: 960px; margin: 16px auto 40px; padding: 0 16px;
           font-size: 12px; color: #6b6256; }}
</style>
</head>
<body>
<header>
  <div class="brand">Practical Legal Tech</div>
  <h1>PrivSweep</h1>
  <p>Runs locally; nothing leaves your machine.</p>
</header>
<main>
  <form method="post" action="/" enctype="multipart/form-data">
    <div class="field">
      <label for="text">Paste text to check</label>
      <textarea id="text" name="text" placeholder="Paste a draft, letter, email, or document text here.">{text}</textarea>
    </div>
    <div class="row">
      <div class="field" style="flex:1; min-width:240px;">
        <label for="upload">...or upload a file</label>
        <input type="file" id="upload" name="upload">
        <div class="hint">.txt, .docx, .pdf, image (image-only PDFs are flagged for manual review)</div>
      </div>
      <div class="field" style="flex:1; min-width:240px;">
        <label for="folder">...or a folder on this machine</label>
        <input type="text" id="folder" name="folder" value="{folder}" placeholder="/path/to/folder">
      </div>
      <div class="field">
        <label for="profile">Destination</label>
        <select id="profile" name="profile">{profile_options}</select>
      </div>
    </div>
    <div class="actions">
      <button type="submit" class="btn-scan" name="action" value="scan" id="run-scan">Scan</button>
      <button type="submit" class="btn-san" name="action" value="sanitize" id="run-sanitize">Sanitize (safe copy)</button>
      <span class="hint">Scan flags what is sensitive. Sanitize is a separate, deliberate step.</span>
    </div>
  </form>
  {results}
</main>
<footer>
  Not legal advice. Detection is best-effort and does not certify a file clean.
  Separator-less SSN/EIN can be missed; sanitize is a text-level de-identifier, not a
  document-preserving redactor; dates are shifted, not masked.
</footer>
<script>
  // Download helper only. No detection logic and no model inference runs in the browser.
  function dlSafeCopy() {{
    var pre = document.getElementById('sanitized');
    if (!pre) return;
    var blob = new Blob([pre.textContent], {{type: 'text/plain'}});
    var a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'privsweep-safe-copy.txt';
    document.body.appendChild(a); a.click(); a.remove();
    URL.revokeObjectURL(a.href);
  }}
</script>
</body>
</html>
"""

_PROFILES = [("", "Any / general"), ("external-llm", "External AI model"),
             ("repo", "Code repository"), ("email", "Email + attachments")]


def _profile_options(selected: str) -> str:
    out = []
    for value, label in _PROFILES:
        sel = " selected" if value == selected else ""
        out.append(f'<option value="{value}"{sel}>{html.escape(label)}</option>')
    return "".join(out)


def _scan_text(text: str, profile: str) -> Report:
    from .engine import scan_doc

    units, parts, offset = [], [], 0
    for i, line in enumerate(text.split("\n"), 1):
        units.append(TextUnit(line, f"line {i}", offset))
        parts.append(line)
        offset += len(line) + 1
    doc = ExtractedDoc(path="(pasted text)", kind="txt", units=units,
                       full_text="\n".join(parts))
    report = Report()
    report.scanned_files.append("(pasted text)")
    report.add(*scan_doc(doc, profile=profile or None))
    _maybe_model_text(report, doc.full_text)
    return report


def _maybe_model_text(report: Report, text: str) -> None:
    """Run the local model over pasted text iff a local server is already running.

    The web request never launches the model; it only uses one that `serve()` (or the
    user) started. The only network call is to 127.0.0.1.
    """
    from . import model

    endpoint = model.model_endpoint()
    if not model._health_ok(endpoint, timeout=2.0):
        report.note("local model not running; showing deterministic checks only.")
        return
    if not model.page_ok_for_model(text, report, "(pasted text)"):
        return
    try:
        result = model.classify(text, endpoint)
    except Exception as exc:  # noqa: BLE001
        report.note(f"local model call failed ({type(exc).__name__}); "
                    f"deterministic results shown.")
        return
    existing = [f.span for f in report.findings if f.span]
    report.add(*model.model_findings(text, result, "(pasted text)",
                                     lambda off: "pasted text", existing))


def _scan_paths(paths, profile: str) -> Report:
    """Scan an uploaded file or a folder path: deterministic engine + model (if running)."""
    from .engine import scan_path

    report = scan_path(paths, profile=profile or None)
    try:
        from .model import augment_with_model
        augment_with_model(paths, report, launch=False)  # use a running server only
    except Exception as exc:  # noqa: BLE001 - model must never break the scan
        report.note(f"model tier error ({type(exc).__name__}); deterministic results stand.")
    return report


def _render_results(report: Report, fail_on: str) -> str:
    from .report import exit_code

    if not report.findings:
        rows = '<tr><td colspan="4">No findings.</td></tr>'
    else:
        rows = ""
        for f in report.sorted_findings():
            cls = _SEV_CLASS[f.severity]
            role = f" ({html.escape(f.role)})" if f.role else ""
            rows += (
                f'<tr><td class="sev {cls}">{f.severity.value}</td>'
                f"<td>{html.escape(f.type)}{role}</td>"
                f"<td>{html.escape(f.location)}</td>"
                f"<td>{html.escape(f.preview)}</td></tr>"
            )
    counts = report.severity_counts()
    failed = exit_code(report, fail_on) == 2
    gate = (f'<span class="gate-fail">FAIL (fail-on={fail_on})</span>' if failed
            else f'<span class="gate-pass">pass (fail-on={fail_on})</span>')
    notes = ""
    if report.notes:
        notes = ('<p class="note">'
                 + "<br>".join(html.escape(n) for n in report.notes) + "</p>")
    return f"""<div class="results" id="results">
      <h2>Scan results</h2>
      <p>{len(report.findings)} finding(s): critical {counts['critical']},
         high {counts['high']}, medium {counts['medium']}, low {counts['low']}.
         Gate: {gate}</p>
      <table><thead><tr><th>Severity</th><th>Type</th><th>Location</th><th>Preview</th></tr></thead>
      <tbody>{rows}</tbody></table>
      {notes}
    </div>"""


def _render_sanitized(text: str, profile: str, *, source_label: str | None = None,
                      notes: list | None = None) -> str:
    from .mapping import Mapping, get_or_create_salt
    from .sanitize import _replaceable_matches, sanitize_text

    mapping = Mapping(salt=get_or_create_salt())
    sanitized, audit = sanitize_text(text, mapping, random.randint(-365, 365))
    src = f" of {html.escape(source_label)}" if source_label else ""
    extra = ""
    if notes:
        extra = '<p class="note">' + "<br>".join(html.escape(n) for n in notes) + "</p>"
    if not sanitized.strip():
        where = " in this file" if source_label else ""
        return (f'<div class="results" id="results"><h2>Sanitized safe copy{src}</h2>'
                f'<p class="note">No readable text was found to sanitize{where}. '
                f"Nothing was changed.</p>{extra}</div>")
    # In-memory verify (V-2): re-scan the safe copy, excluding the pseudonyms produced.
    dummies = {a[3].strip() for a in audit}
    residue = [m for m in _replaceable_matches(sanitized) if m.raw.strip() not in dummies]
    verdict = ('<span class="gate-pass">verified clean</span>' if not residue
               else '<span class="gate-fail">NOT verified clean</span>')
    return f"""<div class="results" id="results">
      <h2>Sanitized safe copy{src}</h2>
      <p class="note">{len(audit)} value(s) replaced; verify: {verdict}. The real-to-dummy
         mapping is kept private and is not shown here.</p>
      <button type="button" class="btn-san" onclick="dlSafeCopy()">Download safe copy</button>
      <pre id="sanitized">{html.escape(sanitized)}</pre>
      {extra}
    </div>"""


def _render_sanitized_folder(folder: str, profile: str) -> str:
    """Sanitize every supported file in a local folder, writing metadata-stripped safe
    copies to a sibling '<folder> - PrivSweep safe copies' directory (kept outside the
    scanned folder so a re-run does not re-scan its own output)."""
    from .sanitize import sanitize_path

    out_dir = folder.rstrip("/\\") + " - PrivSweep safe copies"
    result = sanitize_path(folder, out_dir, profile=profile or None)
    if not result.outputs:
        return ('<div class="results" id="results"><h2>Sanitized safe copies</h2>'
                '<p class="note">No supported files were found in that folder.</p></div>')
    rows = ""
    for p in result.outputs:
        ok = result.verified.get(p, True)
        v = ('<span class="gate-pass">verified clean</span>' if ok
             else '<span class="gate-fail">NOT verified clean</span>')
        rows += f"<tr><td>{html.escape(os.path.basename(p))}</td><td>{v}</td></tr>"
    notes = ""
    if result.notes:
        notes = ('<p class="note">'
                 + "<br>".join(html.escape(n) for n in result.notes) + "</p>")
    return f"""<div class="results" id="results">
      <h2>Sanitized safe copies</h2>
      <p class="note">{len(result.outputs)} file(s) written to
         <code>{html.escape(out_dir)}</code>. The real-to-dummy mapping is kept private.</p>
      <table><thead><tr><th>Safe copy</th><th>Verify</th></tr></thead>
      <tbody>{rows}</tbody></table>
      {notes}
    </div>"""


def _render_error(exc: Exception) -> str:
    """Render a visible error panel so a server-side failure never shows a blank page."""
    return (
        '<div class="results" id="results">'
        "<h2>Something went wrong</h2>"
        '<p class="gate-fail">That action could not be completed.</p>'
        f'<p class="note">{html.escape(type(exc).__name__)}: {html.escape(str(exc))}</p>'
        '<p class="note">Nothing was sent anywhere; this ran entirely on your machine. '
        "Adjust the input and try again.</p>"
        "</div>"
    )


def _page(text: str = "", profile: str = "", folder: str = "", results: str = "") -> str:
    return _PAGE.format(
        text=html.escape(text),
        folder=html.escape(folder),
        profile_options=_profile_options(profile),
        results=results,
    )


def _parse_multipart(content_type: str, body: bytes):
    """Return (fields: dict[str,str], upload: (filename, bytes)|None) from a multipart body."""
    from email.parser import BytesParser
    from email.policy import default as default_policy

    msg = BytesParser(policy=default_policy).parsebytes(
        b"Content-Type: " + content_type.encode("latin-1") + b"\r\n\r\n" + body)
    fields: dict[str, str] = {}
    upload = None
    for part in msg.iter_parts():
        filename = part.get_filename()
        payload = part.get_payload(decode=True) or b""
        if filename:
            upload = (filename, payload)
            continue
        name = part.get_param("name", header="content-disposition")
        if name:
            fields[name] = payload.decode("utf-8", "replace")
    return fields, upload


def _fail_on_for(profile: str) -> str:
    return {"external-llm": "high", "repo": "high", "email": "medium"}.get(profile, "high")


class _Handler(BaseHTTPRequestHandler):
    server_version = "PrivSweep/2.0"

    def log_message(self, *args):  # quiet by default
        pass

    def _send_html(self, body: str, status: int = 200) -> None:
        data = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("X-Content-Type-Options", "nosniff")
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):  # noqa: N802
        path = self.path.split("?")[0]
        if path == "/favicon.ico":
            self.send_response(204)
            self.end_headers()
            return
        if path not in ("/", "/index.html"):
            self._send_html("<h1>Not found</h1>", status=404)
            return
        self._send_html(_page())

    def do_POST(self):  # noqa: N802
        try:
            length = int(self.headers.get("Content-Length", 0))
            body = self.rfile.read(length)
            ctype = self.headers.get("Content-Type", "")
            upload = None
            if ctype.startswith("multipart/form-data"):
                fields, upload = _parse_multipart(ctype, body)
            else:
                parsed = parse_qs(body.decode("utf-8", "replace"), keep_blank_values=True)
                fields = {k: v[0] for k, v in parsed.items()}

            text = fields.get("text", "")
            folder = fields.get("folder", "").strip()
            profile = fields.get("profile", "")
            action = fields.get("action", "scan")
            fail_on = _fail_on_for(profile)

            # Scan and sanitize share the same source priority: an uploaded file, then a
            # folder on this machine, then the pasted text.
            if action == "sanitize":
                if upload and upload[0]:
                    results = self._sanitize_upload(upload, profile)
                elif folder and os.path.isdir(folder):
                    results = _render_sanitized_folder(folder, profile)
                elif folder:
                    results = ('<div class="results"><p class="note">Folder not found: '
                               f"{html.escape(folder)}</p></div>")
                else:
                    results = _render_sanitized(text, profile)
            else:
                if upload and upload[0]:
                    results = self._scan_upload(upload, profile, fail_on)
                elif folder and os.path.isdir(folder):
                    results = _render_results(_scan_paths(folder, profile), fail_on)
                elif folder:
                    results = ('<div class="results"><p class="note">Folder not found: '
                               f"{html.escape(folder)}</p></div>")
                else:
                    results = _render_results(_scan_text(text, profile), fail_on)

            self._send_html(_page(text=text, profile=profile, folder=folder, results=results))
        except Exception as exc:  # noqa: BLE001 - never blank-page; render a readable error
            sys.stderr.write(traceback.format_exc())
            try:
                self._send_html(_page(results=_render_error(exc)), status=500)
            except Exception:  # noqa: BLE001 - response may be partially sent already
                pass

    def _scan_upload(self, upload, profile: str, fail_on: str) -> str:
        filename, data = upload
        suffix = os.path.splitext(filename)[1] or ".txt"
        tmpdir = tempfile.mkdtemp(prefix="privsweep-upload-")
        path = os.path.join(tmpdir, "upload" + suffix)
        try:
            with open(path, "wb") as fh:
                fh.write(data)
            return _render_results(_scan_paths(path, profile), fail_on)
        finally:
            try:
                os.remove(path)
                os.rmdir(tmpdir)
            except OSError:
                pass

    def _sanitize_upload(self, upload, profile: str) -> str:
        """Sanitize an uploaded file: extract its text, then de-identify that text.

        Mirrors _scan_upload's source handling. The uploaded bytes are written to a temp
        file only so the shared extractor can read them; the temp file is removed
        immediately after. Nothing leaves the machine.
        """
        filename, data = upload
        suffix = os.path.splitext(filename)[1] or ".txt"
        tmpdir = tempfile.mkdtemp(prefix="privsweep-upload-")
        path = os.path.join(tmpdir, "upload" + suffix)
        try:
            with open(path, "wb") as fh:
                fh.write(data)
            from .extract import extract
            doc = extract(path)
            return _render_sanitized(doc.full_text, profile,
                                     source_label=filename, notes=doc.notes)
        finally:
            try:
                os.remove(path)
                os.rmdir(tmpdir)
            except OSError:
                pass


def serve(host: str = "127.0.0.1", port: int = 8765, open_browser: bool = True) -> int:
    # First run: fetch the local model once (V-8). Only model weights are downloaded,
    # never user content. Skipped when an endpoint or an explicit model file is configured.
    if not (os.environ.get("PRIVSWEEP_MODEL_ENDPOINT") or os.environ.get("PRIVSWEEP_MODEL_GGUF")):
        try:
            from .model_download import ensure_model, is_present
            if not is_present():
                print("First run: downloading the local language model (one-time setup; "
                      "only model weights are fetched, never your content).")
                ensure_model()
        except Exception as exc:  # noqa: BLE001
            print(f"Model download step skipped ({type(exc).__name__}: {exc}).")

    # Pre-warm the local model once at startup (production); never from a page request.
    try:
        from .model import ensure_server
        endpoint, note, _proc = ensure_server(launch=True)
        if endpoint:
            print(f"Local model ready at {endpoint}")
        elif note:
            print(f"Model: {note}")
    except Exception:  # noqa: BLE001
        pass

    httpd = ThreadingHTTPServer((host, port), _Handler)
    url = f"http://{host}:{port}/"
    print(f"PrivSweep web UI at {url} (Ctrl-C to stop). Local only; no content leaves this machine.")
    if open_browser:
        try:
            import webbrowser
            webbrowser.open(url)
        except Exception:  # noqa: BLE001
            pass
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        httpd.server_close()
    return 0
