import json
import random
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from stages import (STAGES, STAGE_DRESS, STAGE_LEVEL, DEFAULT_STAGE_QUOTAS,
                    dress_for_stage, dress_negative, force_nude_for,
                    load_stage_cameras, parse_stage_quotas, stage_for_record,
                    stage_allows_pose)
from pose_scene import load_affinity, validate_pose_scene
from pack_builder import (Job, _image_name, assign_cameras,
                          choose_outfit_story, compose_stage_jobs,
                          compose_theme_jobs, compute_metrics, load_themes,
                          outfit_line_for_stage, theme_negative)
from select_x_posts import select_posts


class StageModelTest(unittest.TestCase):
    def test_stage_mapping_is_exact(self):
        self.assertEqual(STAGES, ("sugerente", "spicy1", "spicy2",
                                  "explicit1", "explicit2"))
        self.assertEqual([STAGE_LEVEL[s] for s in STAGES],
                         ["sugerente", "spicy", "spicy", "explicito", "explicito"])
        self.assertEqual([STAGE_DRESS[s] for s in STAGES],
                         ["dressed", "stripping", "lingerie", "clothed", "nude"])

    def test_parse_stage_quotas_default_and_valid(self):
        self.assertEqual(parse_stage_quotas(None), DEFAULT_STAGE_QUOTAS)
        self.assertEqual(parse_stage_quotas("1,2,3,4,5"),
                         dict(zip(STAGES, [1, 2, 3, 4, 5])))

    def test_parse_stage_quotas_rejects_bad_values(self):
        for text in ("9,9,9,9", "9,9,9,9,x", "-1,9,9,9,9"):
            with self.assertRaises(ValueError):
                parse_stage_quotas(text)

    def test_stage_for_record_falls_back_to_nivel(self):
        self.assertEqual(stage_for_record({"stage": "spicy2", "nivel": "spicy"}),
                         "spicy2")
        self.assertEqual(stage_for_record({"nivel": "spicy"}), "spicy1")
        self.assertEqual(stage_for_record({"nivel": "explicito"}), "explicit2")
        self.assertEqual(stage_for_record({}), "sugerente")

    def test_dress_and_negative(self):
        self.assertEqual(dress_for_stage("spicy2"), "lingerie")
        self.assertEqual(dress_for_stage("spicy2", "auto"), "lingerie")
        self.assertEqual(dress_for_stage("spicy2", "clothed"), "clothed")
        self.assertEqual(dress_negative("lingerie"), "topless, bottomless")
        self.assertEqual(dress_negative("nude"), "")
        self.assertEqual(force_nude_for("lingerie"), "never")
        self.assertEqual(force_nude_for("nude"), "always")
        with self.assertRaises(ValueError):
            dress_for_stage("nope")


class CameraDataTest(unittest.TestCase):
    def test_missing_file_uses_fallback(self):
        with tempfile.TemporaryDirectory() as td:
            missing = Path(td) / "nope.json"
            got = load_stage_cameras(missing, fallback=["cowboy shot"])
            self.assertEqual(set(got), set(STAGES))
            self.assertTrue(all(v == ["cowboy shot"] for v in got.values()))

    def test_valid_file_missing_stage_uses_fallback(self):
        with tempfile.TemporaryDirectory() as td:
            path = Path(td) / "cameras.json"
            path.write_text(json.dumps({"sugerente": ["portrait"]}),
                            encoding="utf-8")
            got = load_stage_cameras(path, fallback=["cowboy shot"])
            self.assertEqual(got["sugerente"], ["portrait"])
            self.assertEqual(got["explicit2"], ["cowboy shot"])

    def test_real_file_has_no_full_body_and_side_only_where_allowed(self):
        got = load_stage_cameras()
        flat = [c for values in got.values() for c in values]
        self.assertNotIn("full body", flat)
        self.assertNotIn("wide shot", flat)
        self.assertIn("from side", got["spicy2"])
        self.assertIn("from side", got["explicit1"])
        self.assertIn("from side", got["explicit2"])
        self.assertNotIn("from side", got["sugerente"])
        self.assertNotIn("from side", got["spicy1"])


class PoseSceneTest(unittest.TestCase):
    def setUp(self):
        self.aff = load_affinity()

    def test_against_wall_requires_wall(self):
        warnings = validate_pose_scene("1girl, against_wall",
                                       "office, desk, office chair", self.aff)
        self.assertTrue(any("wall" in w.lower() for w in warnings))
        self.assertEqual(validate_pose_scene("1girl, against_wall",
                                             "office, wall, desk", self.aff), [])

    def test_lying_needs_horizontal_support(self):
        warnings = validate_pose_scene("1girl, lying, on_side",
                                       "office, desk, office chair", self.aff)
        self.assertTrue(warnings)
        self.assertEqual(validate_pose_scene("1girl, lying, on_side",
                                             "bedroom, bed", self.aff), [])

    def test_missionary_and_cowgirl(self):
        self.assertTrue(validate_pose_scene("missionary, sex",
                                            "office, desk", self.aff))
        self.assertEqual(validate_pose_scene("cowgirl_position",
                                             "office, office chair", self.aff), [])

    def test_pool_conflict(self):
        warnings = validate_pose_scene("lying", "pool, water", self.aff)
        self.assertTrue(any("pool" in w.lower() or "water" in w.lower()
                            for w in warnings))

    def test_missing_file_is_empty(self):
        with tempfile.TemporaryDirectory() as td:
            self.assertEqual(load_affinity(Path(td) / "nope.yaml"), {"poses": {}})


class OutfitStoryTest(unittest.TestCase):
    def setUp(self):
        self.themes = load_themes()
        self.oficina = self.themes["oficina"]

    def test_office_story_is_consistent(self):
        base, entry = choose_outfit_story(self.oficina, "office_lady")
        self.assertEqual(base, "office_lady")
        self.assertIn("office lady", outfit_line_for_stage(
            self.oficina, base, "sugerente", entry))
        self.assertIn("unbuttoned shirt", outfit_line_for_stage(
            self.oficina, base, "spicy1", entry))
        self.assertIn("black bra", outfit_line_for_stage(
            self.oficina, base, "spicy2", entry))
        self.assertIn("clothed sex", outfit_line_for_stage(
            self.oficina, base, "explicit1", entry))
        self.assertEqual(outfit_line_for_stage(
            self.oficina, base, "explicit2", entry), "")

    def test_preferred_base_and_incomplete_story(self):
        base, _ = choose_outfit_story(self.oficina, "suit")
        self.assertEqual(base, "suit")
        with self.assertRaises(ValueError):
            choose_outfit_story(self.oficina, "nope")
        with self.assertRaises(ValueError):
            choose_outfit_story({"outfit": {"sugerente": {"x": ["shirt"]}}},
                                "x")

    def test_story_missing_spicy2_is_rejected(self):
        broken = {"outfit": {"sugerente": {"x": ["shirt"]}},
                  "outfit_story": {"x": {"spicy1": ["open"],
                                         "explicit1": ["sex"]}}}
        with self.assertRaises(ValueError):
            choose_outfit_story(broken, "x")


class StageCompositionTest(unittest.TestCase):
    def test_one_job_per_stage_and_shared_bank(self):
        bank = [
            {"id": "p1", "nivel": "sugerente", "pose_line": "standing", "tema": []},
            {"id": "p2", "nivel": "spicy", "pose_line": "kneeling", "tema": []},
            {"id": "p3", "nivel": "spicy", "pose_line": "lying", "tema": []},
            {"id": "p4", "nivel": "explicito", "pose_line": "missionary", "tema": []},
            {"id": "p5", "nivel": "explicito", "pose_line": "cum_overflow", "tema": []},
        ]
        pools = {lv: ["pool " + lv] for lv in
                 ("sugerente", "spicy", "explicito")}
        quotas = {s: 1 for s in STAGES}
        jobs = compose_stage_jobs(5, quotas, 0.0, bank, {}, pools, 1,
                                  random.Random(1))
        self.assertEqual([j.stage for j in jobs], list(STAGES))
        self.assertEqual(len({j.pose_id for j in jobs if j.nivel == "spicy"}), 2)
        explicit1 = [j for j in jobs if j.stage == "explicit1"][0]
        self.assertNotEqual(explicit1.pose_line, "cum_overflow")

    def test_explicit1_filters_pool_and_explicit2_allows_it(self):
        self.assertFalse(stage_allows_pose("explicit1", "cum, after_sex"))
        self.assertTrue(stage_allows_pose("explicit2", "cum, after_sex"))
        self.assertFalse(stage_allows_pose("explicit1", "orgasm"))
        self.assertTrue(stage_allows_pose("explicit1", "missionary, blush"))

    def test_empty_quota_returns_no_jobs(self):
        self.assertEqual(compose_stage_jobs(45, {"sugerente": 0}, 0.2, [], {},
                                            {}, 1, random.Random(1)), [])


class StageWiringTest(unittest.TestCase):
    def _theme(self):
        return {
            "seed_tags": ["office"],
            "outfit": {"sugerente": {"office_lady": ["office lady", "white shirt"]}},
            "outfit_story": {"office_lady": {
                "spicy1": ["unbuttoned shirt"],
                "spicy2": ["black bra"],
                "explicit1": ["clothed sex"],
            }},
            "scene": {"office": ["office", "desk"]},
            "lighting": {"window": ["window light"]},
            "props": {"laptop": ["laptop"]},
        }

    def test_base_outfit_is_shared_across_stages(self):
        bank = [
            {"id": "p1", "nivel": "sugerente", "pose_line": "standing", "tema": []},
            {"id": "p2", "nivel": "spicy", "pose_line": "kneeling", "tema": []},
            {"id": "p3", "nivel": "spicy", "pose_line": "lying", "tema": []},
            {"id": "p4", "nivel": "explicito", "pose_line": "missionary", "tema": []},
            {"id": "p5", "nivel": "explicito", "pose_line": "sex", "tema": []},
        ]
        theme = self._theme()
        quotas = {s: 1 for s in STAGES}
        jobs = compose_theme_jobs(
            ["oficina"], 5, DEFAULT_STAGE_QUOTAS, "per-image",
            {"outfit": "office_lady"}, "auto", 0.0, bank, {}, {}, [],
            random.Random(1), themes={"oficina": theme}, max_variants=1,
            stage_quotas=quotas, affinity={"poses": {}})
        self.assertEqual(len(jobs), 5)
        self.assertTrue(all(j.outfit_base == "office_lady" for j in jobs))
        by_stage = {j.stage: j for j in jobs}
        self.assertIn("unbuttoned shirt", by_stage["spicy1"].outfit_line)
        self.assertIn("black bra", by_stage["spicy2"].outfit_line)
        self.assertIn("clothed sex", by_stage["explicit1"].outfit_line)
        self.assertEqual(by_stage["explicit2"].outfit_line, "")
        self.assertEqual(by_stage["spicy2"].dress, "lingerie")
        self.assertEqual(by_stage["explicit2"].dress, "nude")

    def test_story_missing_fails_without_legacy_flag(self):
        theme = {"seed_tags": ["x"], "outfit": {"sugerente": {"x": ["shirt"]}},
                 "scene": {}, "lighting": {}, "props": {}}
        bank = [
            {"id": "p1", "nivel": "sugerente", "pose_line": "standing", "tema": []},
            {"id": "p2", "nivel": "spicy", "pose_line": "kneeling", "tema": []},
            {"id": "p3", "nivel": "spicy", "pose_line": "lying", "tema": []},
            {"id": "p4", "nivel": "explicito", "pose_line": "missionary", "tema": []},
            {"id": "p5", "nivel": "explicito", "pose_line": "sex", "tema": []},
        ]
        with self.assertRaises(SystemExit):
            compose_theme_jobs(["x"], 5, DEFAULT_STAGE_QUOTAS, "per-image", {},
                               "auto", 0.0, bank, {}, {}, [], random.Random(1),
                               themes={"x": theme},
                               stage_quotas={s: 1 for s in STAGES},
                               affinity={"poses": {}})

    def test_legacy_outfit_opt_in_marks_fallback(self):
        theme = {"seed_tags": ["x"], "outfit": {"sugerente": {"x": ["shirt"]}},
                 "scene": {}, "lighting": {}, "props": {}}
        bank = [{"id": "p1", "nivel": "sugerente", "pose_line": "standing",
                 "tema": []}]
        jobs = compose_theme_jobs(
            ["x"], 1, DEFAULT_STAGE_QUOTAS, "per-image", {}, "auto", 0.0,
            bank, {}, {}, [], random.Random(1), themes={"x": theme},
            stage_quotas={"sugerente": 1}, allow_legacy_outfit=True,
            affinity={"poses": {}})
        self.assertEqual(len(jobs), 1)
        self.assertTrue(jobs[0].outfit_fallback)
        self.assertIn("shirt", jobs[0].outfit_line)

    def test_strict_scene_drops_conflict(self):
        theme = self._theme()
        bank = [{"id": "p1", "nivel": "sugerente",
                 "pose_line": "against_wall, standing", "tema": []}]
        jobs = compose_theme_jobs(
            ["oficina"], 1, DEFAULT_STAGE_QUOTAS, "per-image", {},
            "auto", 0.0, bank, {}, {}, [], random.Random(1),
            themes={"oficina": theme}, max_variants=1,
            stage_quotas={"sugerente": 1},
            affinity={"poses": {"against_wall": {"requires": ["wall"]}}},
            strict_scene=True)
        self.assertEqual(jobs, [])

    def test_negative_and_image_name(self):
        job = Job("spicy", "pose", "bank", "pb1", ["oficina"], "")
        job.stage = "spicy2"
        job.dress = "lingerie"
        self.assertEqual(theme_negative(job, "bad"), "bad, topless, bottomless")
        job.dress = "nude"
        self.assertEqual(theme_negative(job, "bad"), "bad")
        self.assertEqual(_image_name(7, job, 123), "07_spicy2_pb1_s123.png")
        job.stage = None
        self.assertEqual(_image_name(7, job, 123), "07_spicy_pb1_s123.png")

    def test_metrics_by_stage(self):
        metrics = compute_metrics(
            [{"nivel": "spicy", "stage": "spicy2"},
             {"nivel": "explicito", "stage": "explicit1"}],
            planned={"sugerente": 0, "spicy": 1, "explicito": 1},
            planned_stage={"sugerente": 0, "spicy1": 0, "spicy2": 1,
                           "explicit1": 1, "explicit2": 0})
        self.assertEqual(metrics["by_stage"]["spicy2"], 1)
        self.assertEqual(metrics["by_stage"]["explicit1"], 1)
        self.assertEqual(metrics["shortfall_stage"]["spicy2"], 0)


class StageCameraTest(unittest.TestCase):
    def test_side_is_not_forced(self):
        jobs = [
            Job("spicy", "lying, on_side", "bank", "pb1", [], ""),
            Job("spicy", "lying, on_side", "bank", "pb2", [], ""),
        ]
        for job in jobs:
            job.stage = "spicy2"
        assign_cameras(jobs, ["cowboy shot"],
                       stage_cameras={"spicy2": ["cowboy shot", "from side"]})
        self.assertNotEqual([j.camera for j in jobs], ["from side", "from side"])
        self.assertEqual(jobs[0].camera, "cowboy shot")
        self.assertEqual(jobs[1].camera, "from side")

    def test_stage_without_side_never_gets_it(self):
        job = Job("sugerente", "lying", "bank", "pb1", [], "")
        job.stage = "sugerente"
        assign_cameras([job], ["from side"],
                       stage_cameras={"sugerente": ["portrait"]})
        self.assertEqual(job.camera, "portrait")

    def test_empty_pool_is_empty(self):
        job = Job("spicy", "pose", "bank", "pb1", [], "")
        job.stage = "spicy1"
        assign_cameras([job], [], stage_cameras={"spicy1": []})
        self.assertEqual(job.camera, "")


class CliTest(unittest.TestCase):
    def test_cli_rejects_levels_and_stages_together(self):
        import subprocess
        result = subprocess.run(
            [sys.executable, "Engine/pack_builder.py", "--char", "Makima_v2",
             "--theme", "oficina", "--pack", "--dry-run",
             "--levels", "15,16,14", "--stages", "9,9,9,9,9"],
            cwd=Path(__file__).resolve().parent.parent,
            text=True, capture_output=True)
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("no se pueden usar", result.stdout + result.stderr)


class SelectPostsTest(unittest.TestCase):
    def test_spicy2_is_explicit_and_old_spicy_is_sfw(self):
        records = [
            {"nivel": "spicy", "file": "old.png"},
            {"nivel": "spicy", "stage": "spicy2", "file": "new.png"},
            {"nivel": "explicito", "stage": "explicit1", "file": "exp.png"},
        ]
        posts = select_posts(records, sfw=1, exp=1, per_post=4)
        sfw = [f for p in posts if p["kind"] == "sfw" for f in p["files"]]
        exp = [f for p in posts if p["kind"] == "exp" for f in p["files"]]
        self.assertIn("old.png", sfw)
        self.assertNotIn("new.png", sfw)
        self.assertIn("new.png", exp)
        self.assertIn("exp.png", exp)


if __name__ == "__main__":
    unittest.main()
